feat(panel): implement user management administration panel with sessions and minecraft link support
This commit is contained in:
28 files changed
+4260
-65
No files matched your search
@@ -326,9 +326,9 @@ func provisionOwner(ctx context.Context, s ownerStore, username, email, password
|
|||||||
}
|
}
|
||||||
|
|
||||||
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the
|
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the
|
||||||
// Owner it is role=admin with must_change_password=true — Felis has no separate
|
// Owner it requires must_change_password=true but carries role='admin' (the single
|
||||||
// operator DB role, so an Operator is simply an additional staff admin (migration
|
// above-admin 'owner' role was added in migration 0011 and is exclusive to the first
|
||||||
// 0003). UNLIKE provisionOwner, which upserts the single Owner and resets it on a
|
// account — every subsequent staff is a plain admin). UNLIKE provisionOwner, which
|
||||||
// username conflict, this is insert-only: a username already taken returns
|
// username conflict, this is insert-only: a username already taken returns
|
||||||
// api.ErrConflict rather than overwriting a live account, so adding an Operator can
|
// api.ErrConflict rather than overwriting a live account, so adding an Operator can
|
||||||
// never silently clobber the Owner's or another Operator's credential. Only the
|
// never silently clobber the Owner's or another Operator's credential. Only the
|
||||||
|
|||||||
+509
-1
@@ -68,6 +68,8 @@ tags:
|
|||||||
description: Create / mutate server specs (external face, admin tier).
|
description: Create / mutate server specs (external face, admin tier).
|
||||||
- name: images
|
- name: images
|
||||||
description: Image build and whitelist administration (external face, admin tier).
|
description: Image build and whitelist administration (external face, admin tier).
|
||||||
|
- name: users
|
||||||
|
description: User administration (external face, admin tier only — exposed solely to owner).
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
@@ -326,6 +328,74 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
description: Null until an admin approves or rejects.
|
description: Null until an admin approves or rejects.
|
||||||
|
|
||||||
|
UserView:
|
||||||
|
type: object
|
||||||
|
description: One row of the admin user list (internal/api/repo.go UserView).
|
||||||
|
required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
username: { type: string }
|
||||||
|
email: { type: string }
|
||||||
|
role: { type: string, enum: [admin, user] }
|
||||||
|
disabled: { type: boolean }
|
||||||
|
email_verified: { type: boolean }
|
||||||
|
server_count: { type: integer }
|
||||||
|
must_change_password: { type: boolean }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
UserDetail:
|
||||||
|
type: object
|
||||||
|
description: Full admin view of one user (internal/api/repo.go UserDetail).
|
||||||
|
required: [id, username, role, disabled, email_verified, must_change_password, server_count, created_at, updated_at, linked_accounts]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
username: { type: string }
|
||||||
|
email: { type: string }
|
||||||
|
role: { type: string, enum: [admin, user] }
|
||||||
|
disabled: { type: boolean }
|
||||||
|
email_verified: { type: boolean }
|
||||||
|
server_count: { type: integer }
|
||||||
|
must_change_password: { type: boolean }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
deleted_at:
|
||||||
|
type: [string, 'null']
|
||||||
|
format: date-time
|
||||||
|
description: Present only when soft-deleted.
|
||||||
|
linked_accounts:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [mc_uuid, auth_source, verified_at]
|
||||||
|
properties:
|
||||||
|
mc_uuid: { type: string, format: uuid }
|
||||||
|
auth_source: { type: string }
|
||||||
|
verified_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
QuotaView:
|
||||||
|
type: object
|
||||||
|
description: A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited.
|
||||||
|
required: [user_id]
|
||||||
|
properties:
|
||||||
|
user_id: { type: string }
|
||||||
|
max_servers: { type: integer, nullable: true }
|
||||||
|
max_cpu_milli: { type: integer, nullable: true }
|
||||||
|
max_memory_mb: { type: integer, nullable: true }
|
||||||
|
max_storage_gb: { type: integer, nullable: true }
|
||||||
|
|
||||||
|
SessionView:
|
||||||
|
type: object
|
||||||
|
description: One live session of a user visible to an admin (internal/api/repo.go SessionView).
|
||||||
|
required: [token_hash, created_at, expires_at]
|
||||||
|
properties:
|
||||||
|
token_hash: { type: string }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
expires_at: { type: string, format: date-time }
|
||||||
|
revoked_at:
|
||||||
|
type: [string, 'null']
|
||||||
|
format: date-time
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
# ----------------------------------------------------------------- health ---
|
# ----------------------------------------------------------------- health ---
|
||||||
/healthz:
|
/healthz:
|
||||||
@@ -1785,13 +1855,451 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'409':
|
'409':
|
||||||
description: Server is not stopped.
|
description: Submission has already been reviewed.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
|
# ------------------------------------------------------ users (admin tier) ----
|
||||||
|
/api/v1/users:
|
||||||
|
get:
|
||||||
|
tags: [users]
|
||||||
|
operationId: listUsers
|
||||||
|
summary: List users (admin only).
|
||||||
|
description: >-
|
||||||
|
Returns a page of non-deleted users matching optional query filters, newest
|
||||||
|
first. Every route under /users gates on the admin Zero-Trust path.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: query, in: query, required: false, schema: { type: string }, description: Substring match on username or email }
|
||||||
|
- { name: role, in: query, required: false, schema: { type: string, enum: [admin, user] } }
|
||||||
|
- { name: disabled, in: query, required: false, schema: { type: string, enum: ["true", "false"] } }
|
||||||
|
- { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
|
||||||
|
- { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: A page of users plus the total unfiltered count.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [users, total]
|
||||||
|
properties:
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/UserView' }
|
||||||
|
total: { type: integer }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
post:
|
||||||
|
tags: [users]
|
||||||
|
operationId: createUser
|
||||||
|
summary: Create a user (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [username, role, password]
|
||||||
|
properties:
|
||||||
|
username: { type: string }
|
||||||
|
email: { type: string, format: email }
|
||||||
|
role: { type: string, enum: [admin, user] }
|
||||||
|
password: { type: string, format: password }
|
||||||
|
must_change_password: { type: boolean, default: true }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: User created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/UserView' }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
description: Username already taken.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
|
/api/v1/users/{id}:
|
||||||
|
get:
|
||||||
|
tags: [users]
|
||||||
|
operationId: getUser
|
||||||
|
summary: Get user detail (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Full user detail including linked MC accounts.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/UserDetail' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
patch:
|
||||||
|
tags: [users]
|
||||||
|
operationId: patchUser
|
||||||
|
summary: Edit a user (admin only, cannot patch self).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username: { type: string }
|
||||||
|
email: { type: string, format: email }
|
||||||
|
role: { type: string, enum: [admin, user] }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/UserView' }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
description: Username conflict.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
delete:
|
||||||
|
tags: [users]
|
||||||
|
operationId: deleteUser
|
||||||
|
summary: Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User soft-deleted.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [deleted]
|
||||||
|
properties:
|
||||||
|
deleted: { type: boolean, const: true }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/api/v1/users/{id}/disable:
|
||||||
|
post:
|
||||||
|
tags: [users]
|
||||||
|
operationId: disableUser
|
||||||
|
summary: Disable or re-enable a user (admin only, cannot disable self).
|
||||||
|
description: >-
|
||||||
|
Disabling a user additionally revokes every live session so the lockout is
|
||||||
|
immediate. Re-enabling simply clears the flag.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [disabled]
|
||||||
|
properties:
|
||||||
|
disabled: { type: boolean }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Toggle applied.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, disabled]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
disabled: { type: boolean }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/api/v1/users/{id}/reset-password:
|
||||||
|
post:
|
||||||
|
tags: [users]
|
||||||
|
operationId: resetPassword
|
||||||
|
summary: >-
|
||||||
|
Generate a high-entropy random password, deliver it to the user's email, and
|
||||||
|
force a first-login change (admin only). No request body — the server owns
|
||||||
|
entropy. The password is never returned to the admin; only the target email is echoed.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Password reset. All existing sessions revoked. Password sent to the user's email.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ok, email]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, const: true }
|
||||||
|
email: { type: string, description: "The recipient email (empty if the user has none)." }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/api/v1/users/{id}/quotas:
|
||||||
|
get:
|
||||||
|
tags: [users]
|
||||||
|
operationId: getQuotas
|
||||||
|
summary: Get a user's quotas (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The user's current quotas (null=unlimited).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/QuotaView' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
put:
|
||||||
|
tags: [users]
|
||||||
|
operationId: setQuotas
|
||||||
|
summary: Set a user's quotas (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
max_servers: { type: integer, nullable: true }
|
||||||
|
max_cpu_milli: { type: integer, nullable: true }
|
||||||
|
max_memory_mb: { type: integer, nullable: true }
|
||||||
|
max_storage_gb: { type: integer, nullable: true }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Quotas updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/QuotaView' }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
|
/api/v1/users/{id}/sessions:
|
||||||
|
get:
|
||||||
|
tags: [users]
|
||||||
|
operationId: listUserSessions
|
||||||
|
summary: List a user's live sessions (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Live (unrevoked, unexpired) sessions, newest first.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [sessions]
|
||||||
|
properties:
|
||||||
|
sessions:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/SessionView' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
delete:
|
||||||
|
tags: [users]
|
||||||
|
operationId: revokeUserSessions
|
||||||
|
summary: Revoke every live session of a user (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: All sessions revoked.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ok]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, const: true }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
|
/api/v1/users/{id}/sessions/{hash}:
|
||||||
|
delete:
|
||||||
|
tags: [users]
|
||||||
|
operationId: revokeUserSession
|
||||||
|
summary: Revoke a single session of a user (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: hash, in: path, required: true, schema: { type: string } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session revoked.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ok]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, const: true }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
|
||||||
|
/api/v1/users/{id}/links:
|
||||||
|
post:
|
||||||
|
tags: [users]
|
||||||
|
operationId: linkAccount
|
||||||
|
summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only).
|
||||||
|
description: >-
|
||||||
|
The UUID must not already be bound to a different user (409). Same (user, uuid)
|
||||||
|
pair is idempotent (200). auth_source defaults to "mojang".
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [mc_uuid]
|
||||||
|
properties:
|
||||||
|
mc_uuid: { type: string, format: uuid }
|
||||||
|
auth_source: { type: string, enum: [mojang, thirdparty], default: mojang }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: UUID linked (or was already linked to this user).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ok, mc_uuid, auth_source]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, const: true }
|
||||||
|
mc_uuid: { type: string, format: uuid }
|
||||||
|
auth_source: { type: string }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'409':
|
||||||
|
description: UUID is already linked to a different user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
|
/api/v1/users/{id}/links/{mc_uuid}:
|
||||||
|
delete:
|
||||||
|
tags: [users]
|
||||||
|
operationId: unlinkAccount
|
||||||
|
summary: Remove a single Minecraft UUID binding from a user (admin only).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: UUID unlinked.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [ok, mc_uuid]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, const: true }
|
||||||
|
mc_uuid: { type: string, format: uuid }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
description: No linked account for this UUID.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
/api/v1/account/link/start:
|
/api/v1/account/link/start:
|
||||||
post:
|
post:
|
||||||
tags: [account]
|
tags: [account]
|
||||||
|
|||||||
+36
-3
@@ -73,6 +73,11 @@ type API struct {
|
|||||||
// sender. The code is never returned to the client on either path.
|
// sender. The code is never returned to the client on either path.
|
||||||
Mailer OTPMailer
|
Mailer OTPMailer
|
||||||
|
|
||||||
|
// ResetMailer delivers admin-generated password-reset passwords to the user's
|
||||||
|
// verified email address. Same nil→server-side-log pattern as Mailer; the
|
||||||
|
// password is never returned to the admin caller. Production wires a real sender.
|
||||||
|
ResetMailer ResetMailer
|
||||||
|
|
||||||
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
|
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
|
||||||
// passkey bind). It is optional: when nil the passkey register routes report 503
|
// passkey bind). It is optional: when nil the passkey register routes report 503
|
||||||
// rather than panic, so the authenticated enrollment boundary is exercised before
|
// rather than panic, so the authenticated enrollment boundary is exercised before
|
||||||
@@ -219,6 +224,13 @@ type apiRoute struct {
|
|||||||
// handler). Internal-face routes never set it.
|
// handler). Internal-face routes never set it.
|
||||||
Admin bool
|
Admin bool
|
||||||
|
|
||||||
|
// Owner marks an external-face route that requires the platform-level owner
|
||||||
|
// role (Principal.IsOwner()). It is orthogonal to Admin: an owner
|
||||||
|
// intrinsically passes the admin ZT gate (IsAdmin() accepts both admin and
|
||||||
|
// owner), so a route that sets Owner does not also need Admin. Mixing both
|
||||||
|
// on one route is harmless but redundant — an owner passes both.
|
||||||
|
Owner bool
|
||||||
|
|
||||||
// AllowDuringPasswordChange opts a route OUT of the must_change_password
|
// AllowDuringPasswordChange opts a route OUT of the must_change_password
|
||||||
// lockdown (spec §B). The lockdown is default-deny: every authenticated route is
|
// lockdown (spec §B). The lockdown is default-deny: every authenticated route is
|
||||||
// fenced off for a staff principal that still owes a first-login password change
|
// fenced off for a staff principal that still owes a first-login password change
|
||||||
@@ -409,6 +421,24 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// only — the runner/executors that consume the window are still INTEGRATION-ONLY.
|
// only — the runner/executors that consume the window are still INTEGRATION-ONLY.
|
||||||
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
|
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
|
||||||
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},
|
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},
|
||||||
|
|
||||||
|
// User admin (spec §7, owner-only). Every route gates on the admin Zero-Trust
|
||||||
|
// path AND the owner role: listing, mutating, disabling, or deleting users is
|
||||||
|
// an owner-tier operation (one level above admin).
|
||||||
|
{Method: "GET", Pattern: "/api/v1/users", Owner: true, h: a.handleListUsers},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/users", Owner: true, h: a.handleCreateUser},
|
||||||
|
{Method: "GET", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handleGetUser},
|
||||||
|
{Method: "PATCH", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handlePatchUser},
|
||||||
|
{Method: "DELETE", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handleDeleteUser},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/users/{id}/disable", Owner: true, h: a.handleDisableUser},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/users/{id}/reset-password", Owner: true, h: a.handleResetPassword},
|
||||||
|
{Method: "GET", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleGetQuotas},
|
||||||
|
{Method: "PUT", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleSetQuotas},
|
||||||
|
{Method: "GET", Pattern: "/api/v1/users/{id}/sessions", Owner: true, h: a.handleListUserSessions},
|
||||||
|
{Method: "DELETE", Pattern: "/api/v1/users/{id}/sessions", Owner: true, h: a.handleRevokeUserSessions},
|
||||||
|
{Method: "DELETE", Pattern: "/api/v1/users/{id}/sessions/{hash}", Owner: true, h: a.handleRevokeUserSession},
|
||||||
|
{Method: "DELETE", Pattern: "/api/v1/users/{id}/links/{mc_uuid}", Owner: true, h: a.handleUnlinkAccount},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/users/{id}/links", Owner: true, h: a.handleLinkAccount},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,9 +458,9 @@ func (a *API) ExternalHandler() http.Handler {
|
|||||||
// buildFace assembles one face from its route table. Public routes are mounted
|
// buildFace assembles one face from its route table. Public routes are mounted
|
||||||
// unauthenticated on the outer mux; the rest go on an inner mux behind guard
|
// unauthenticated on the outer mux; the rest go on an inner mux behind guard
|
||||||
// (requireInternal / requireExternal), with Admin routes additionally wrapped in
|
// (requireInternal / requireExternal), with Admin routes additionally wrapped in
|
||||||
// adminOnly. Because both faces are built from the same table the OpenAPI parity
|
// adminOnly, and Owner routes in ownerOnly. Because both faces are built from the
|
||||||
// test reads, the served surface and the documented surface cannot drift apart
|
// same table the OpenAPI parity test reads, the served surface and the documented
|
||||||
// without failing the build.
|
// surface cannot drift apart without failing the build.
|
||||||
func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler) http.Handler {
|
func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
auth := http.NewServeMux()
|
auth := http.NewServeMux()
|
||||||
@@ -441,6 +471,9 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
h := rt.h
|
h := rt.h
|
||||||
|
if rt.Owner {
|
||||||
|
h = a.ownerOnly(rt.h)
|
||||||
|
}
|
||||||
if rt.Admin {
|
if rt.Admin {
|
||||||
h = a.adminOnly(rt.h)
|
h = a.adminOnly(rt.h)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ type fakeRepo struct {
|
|||||||
// just as the PG query does.
|
// just as the PG query does.
|
||||||
passkeyCreds map[string]PasskeyCredential
|
passkeyCreds map[string]PasskeyCredential
|
||||||
passkeyChallenges map[string]*fakePasskeyChallenge
|
passkeyChallenges map[string]*fakePasskeyChallenge
|
||||||
|
// user admin fakes
|
||||||
|
seededUsers []seededUser
|
||||||
|
fakeQuotas map[string]*QuotaView
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
|
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
|
||||||
@@ -157,6 +160,7 @@ func newFakeRepo() *fakeRepo {
|
|||||||
holds: map[string]fakeDataHold{},
|
holds: map[string]fakeDataHold{},
|
||||||
passkeyCreds: map[string]PasskeyCredential{},
|
passkeyCreds: map[string]PasskeyCredential{},
|
||||||
passkeyChallenges: map[string]*fakePasskeyChallenge{},
|
passkeyChallenges: map[string]*fakePasskeyChallenge{},
|
||||||
|
fakeQuotas: map[string]*QuotaView{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,6 +635,233 @@ func (f *fakeRepo) SetSetting(_ context.Context, key string, value []byte) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- user admin fakes ----
|
||||||
|
|
||||||
|
// seededUser is a test-only user row held in the fake repo.
|
||||||
|
type seededUser struct {
|
||||||
|
view UserView
|
||||||
|
detail UserDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) seedUser(u UserView) {
|
||||||
|
su := &StaffUser{ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role}
|
||||||
|
f.staff[u.Username] = su
|
||||||
|
f.seededUsers = append(f.seededUsers, seededUser{view: u, detail: UserDetail{UserView: u}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) ListUsers(_ context.Context, opts ListUsersOpts) ([]UserView, int, error) {
|
||||||
|
var filtered []UserView
|
||||||
|
for _, su := range f.seededUsers {
|
||||||
|
u := su.view
|
||||||
|
if opts.Query != "" {
|
||||||
|
q := strings.ToLower(opts.Query)
|
||||||
|
ul := strings.ToLower(u.Username)
|
||||||
|
el := strings.ToLower(u.Email)
|
||||||
|
if !strings.Contains(ul, q) && !strings.Contains(el, q) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Role != "" && u.Role != opts.Role {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch opts.Hidden {
|
||||||
|
case "true":
|
||||||
|
if !u.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case "false":
|
||||||
|
if u.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered = append(filtered, u)
|
||||||
|
}
|
||||||
|
total := len(filtered)
|
||||||
|
limit := opts.Limit
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
offset := opts.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
if offset >= len(filtered) {
|
||||||
|
return []UserView{}, total, nil
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(filtered) {
|
||||||
|
end = len(filtered)
|
||||||
|
}
|
||||||
|
// Newest first — the PG orders by created_at DESC too.
|
||||||
|
for i, j := 0, len(filtered)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
filtered[i], filtered[j] = filtered[j], filtered[i]
|
||||||
|
}
|
||||||
|
return filtered[offset:end], total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) UserDetail(_ context.Context, userID string) (*UserDetail, error) {
|
||||||
|
for _, su := range f.seededUsers {
|
||||||
|
if su.view.ID == userID {
|
||||||
|
return &su.detail, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) CreateUser(_ context.Context, input CreateUserInput, _ string) (*UserView, error) {
|
||||||
|
for _, su := range f.seededUsers {
|
||||||
|
if su.view.Username == input.Username {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := "test-" + input.Username
|
||||||
|
u := UserView{
|
||||||
|
ID: id, Username: input.Username, Email: input.Email,
|
||||||
|
Role: input.Role, MustChangePassword: input.MustChange,
|
||||||
|
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
d := UserDetail{UserView: u}
|
||||||
|
f.seededUsers = append(f.seededUsers, seededUser{view: u, detail: d})
|
||||||
|
f.staff[input.Username] = &StaffUser{ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) UpdateUser(_ context.Context, userID string, patch UpdateUserInput, _ string) (*UserView, error) {
|
||||||
|
for i, su := range f.seededUsers {
|
||||||
|
if su.view.ID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if patch.Username != nil {
|
||||||
|
for _, other := range f.seededUsers {
|
||||||
|
if other.view.ID != userID && other.view.Username == *patch.Username {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.seededUsers[i].view.Username = *patch.Username
|
||||||
|
f.seededUsers[i].detail.Username = *patch.Username
|
||||||
|
}
|
||||||
|
if patch.Email != nil {
|
||||||
|
f.seededUsers[i].view.Email = *patch.Email
|
||||||
|
f.seededUsers[i].detail.Email = *patch.Email
|
||||||
|
}
|
||||||
|
if patch.Role != nil {
|
||||||
|
f.seededUsers[i].view.Role = *patch.Role
|
||||||
|
f.seededUsers[i].detail.Role = *patch.Role
|
||||||
|
}
|
||||||
|
v := f.seededUsers[i].view
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) DeleteUser(_ context.Context, userID, _ string) error {
|
||||||
|
for i, su := range f.seededUsers {
|
||||||
|
if su.view.ID == userID {
|
||||||
|
f.seededUsers = append(f.seededUsers[:i], f.seededUsers[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bool) error {
|
||||||
|
for i, su := range f.seededUsers {
|
||||||
|
if su.view.ID == userID {
|
||||||
|
f.seededUsers[i].view.Disabled = disabled
|
||||||
|
f.seededUsers[i].detail.Disabled = disabled
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) AdminResetPassword(_ context.Context, userID, passwordHash string) error {
|
||||||
|
for _, su := range f.seededUsers {
|
||||||
|
if su.view.ID == userID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- quota admin fakes ----
|
||||||
|
|
||||||
|
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
|
||||||
|
v := &QuotaView{UserID: userID}
|
||||||
|
if f.fakeQuotas == nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if qv, ok := f.fakeQuotas[userID]; ok {
|
||||||
|
v.MaxServers = qv.MaxServers
|
||||||
|
v.MaxCPUMilli = qv.MaxCPUMilli
|
||||||
|
v.MaxMemoryMB = qv.MaxMemoryMB
|
||||||
|
v.MaxStorageGB = qv.MaxStorageGB
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _ string) (*QuotaView, error) {
|
||||||
|
if f.fakeQuotas == nil {
|
||||||
|
f.fakeQuotas = map[string]*QuotaView{}
|
||||||
|
}
|
||||||
|
if _, ok := f.fakeQuotas[userID]; !ok {
|
||||||
|
f.fakeQuotas[userID] = &QuotaView{UserID: userID}
|
||||||
|
}
|
||||||
|
if qi.MaxServers != nil {
|
||||||
|
f.fakeQuotas[userID].MaxServers = qi.MaxServers
|
||||||
|
}
|
||||||
|
if qi.MaxCPUMilli != nil {
|
||||||
|
f.fakeQuotas[userID].MaxCPUMilli = qi.MaxCPUMilli
|
||||||
|
}
|
||||||
|
if qi.MaxMemoryMB != nil {
|
||||||
|
f.fakeQuotas[userID].MaxMemoryMB = qi.MaxMemoryMB
|
||||||
|
}
|
||||||
|
if qi.MaxStorageGB != nil {
|
||||||
|
f.fakeQuotas[userID].MaxStorageGB = qi.MaxStorageGB
|
||||||
|
}
|
||||||
|
return f.fakeQuotas[userID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- session admin fakes ----
|
||||||
|
|
||||||
|
func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) {
|
||||||
|
var out []SessionView
|
||||||
|
for hash, s := range f.sessions {
|
||||||
|
if s.userID == userID && !s.revoked && s.expiresAt.After(now) {
|
||||||
|
out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) RevokeAllUserSessions(_ context.Context, userID string) error {
|
||||||
|
for _, s := range f.sessions {
|
||||||
|
if s.userID == userID {
|
||||||
|
s.revoked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) UnlinkAccount(_ context.Context, userID, mcUUID string) error {
|
||||||
|
if f.links[mcUUID] != userID {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.links, mcUUID)
|
||||||
|
delete(f.linkAuthSource, mcUUID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) LinkAccount(_ context.Context, userID, mcUUID, authSource string) error {
|
||||||
|
if existing, ok := f.links[mcUUID]; ok && existing != userID {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
f.links[mcUUID] = userID
|
||||||
|
f.linkAuthSource[mcUUID] = authSource
|
||||||
|
f.linked[userID] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// fakeRestorer records the restore it was asked to start and returns a canned
|
// fakeRestorer records the restore it was asked to start and returns a canned
|
||||||
// error, mirroring the Restorer kick-off contract. The real restore Job is
|
// error, mirroring the Restorer kick-off contract. The real restore Job is
|
||||||
// integration-only, so the handler is tested against this fake (spec §466).
|
// integration-only, so the handler is tested against this fake (spec §466).
|
||||||
|
|||||||
+10
-1
@@ -36,8 +36,17 @@ type Principal struct {
|
|||||||
// IsAdmin reports whether the principal may perform admin-tier operations.
|
// IsAdmin reports whether the principal may perform admin-tier operations.
|
||||||
// Both the role claim and the admin Access path are required: a role=admin
|
// Both the role claim and the admin Access path are required: a role=admin
|
||||||
// session arriving on panel.* must not bypass the Zero-Trust boundary.
|
// session arriving on panel.* must not bypass the Zero-Trust boundary.
|
||||||
|
// An owner implicitly passes this check (the owner role is a superset of admin).
|
||||||
func (p *Principal) IsAdmin() bool {
|
func (p *Principal) IsAdmin() bool {
|
||||||
return p != nil && p.Role == "admin" && p.ViaAdminAccess
|
return p != nil && (p.Role == "admin" || p.Role == "owner") && p.ViaAdminAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOwner reports whether the principal holds the platform-level owner role
|
||||||
|
// — the single identity that may manage users, quotas, and sessions. Only the
|
||||||
|
// first staff account minted by break-glass carries this role; every subsequent
|
||||||
|
// Operator is a plain admin. Like IsAdmin, it requires the admin Access path.
|
||||||
|
func (p *Principal) IsOwner() bool {
|
||||||
|
return p != nil && p.Role == "owner" && p.ViaAdminAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// InternalAuth authenticates the internal face (velocity / backend callbacks):
|
// InternalAuth authenticates the internal face (velocity / backend callbacks):
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
"email": p.Email,
|
"email": p.Email,
|
||||||
"role": p.Role,
|
"role": p.Role,
|
||||||
"is_admin": p.IsAdmin(),
|
"is_admin": p.IsAdmin(),
|
||||||
|
"is_owner": p.IsOwner(),
|
||||||
"email_verified": emailVerified,
|
"email_verified": emailVerified,
|
||||||
// must_change_password is meaningful only on the local-password path; the JWT
|
// must_change_password is meaningful only on the local-password path; the JWT
|
||||||
// path leaves it false. The panel uses it to route a freshly-provisioned staff
|
// path leaves it false. The panel uses it to route a freshly-provisioned staff
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResetMailer delivers a freshly-generated admin-reset password to the user's
|
||||||
|
// verified email address. nil means the password is logged server-side (the
|
||||||
|
// KNOWN-LIMITATION pattern from OTPMailer — production wires a real sender).
|
||||||
|
// The password is never returned to the admin caller.
|
||||||
|
type ResetMailer interface {
|
||||||
|
SendPasswordReset(ctx context.Context, email, password string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- user CRUD ----
|
||||||
|
|
||||||
|
// handleListUsers is the admin-tier user list (GET /users). It gates on
|
||||||
|
// adminOnly, so the caller is already a verified admin principal.
|
||||||
|
func (a *API) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||||
|
|
||||||
|
opts := ListUsersOpts{
|
||||||
|
Query: q.Get("query"),
|
||||||
|
Role: q.Get("role"),
|
||||||
|
Hidden: q.Get("disabled"),
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
users, total, err := a.Repo.ListUsers(r.Context(), opts)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if users == nil {
|
||||||
|
users = []UserView{}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = p // admin check done by adminOnly middleware
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"users": users, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetUser is the admin-tier user detail (GET /users/{id}).
|
||||||
|
func (a *API) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := a.Repo.UserDetail(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserRequest is the admin create-user form.
|
||||||
|
type createUserRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
MustChange bool `json:"must_change_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateUser is the admin-tier create-user endpoint (POST /users).
|
||||||
|
func (a *API) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
|
||||||
|
var body createUserRequest
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username: 1–32 alphanumeric + limited symbols, no whitespace.
|
||||||
|
if err := validateUsername(body.Username); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role.
|
||||||
|
if body.Role != "admin" && body.Role != "user" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"role must be 'admin' or 'user', got %q", body.Role))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password: 8–72 bytes (bcrypt limit).
|
||||||
|
if len(body.Password) < 8 {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "weak_password",
|
||||||
|
"password must be at least 8 characters"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Password) > 72 {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"password must be at most 72 characters"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := a.Repo.CreateUser(r.Context(), CreateUserInput{
|
||||||
|
Username: body.Username,
|
||||||
|
Email: body.Email,
|
||||||
|
Role: body.Role,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
MustChange: body.MustChange,
|
||||||
|
}, p.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "already_exists",
|
||||||
|
"username %q is already taken", body.Username))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.create", u.ID)
|
||||||
|
writeJSON(w, http.StatusCreated, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchUserRequest is the admin patch-user form. Every field is a pointer so
|
||||||
|
// "absent" is distinguishable from "set to empty".
|
||||||
|
type patchUserRequest struct {
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
Email *string `json:"email,omitempty"`
|
||||||
|
Role *string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePatchUser is the admin-tier patch-user endpoint (PATCH /users/{id}).
|
||||||
|
func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body patchUserRequest
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Username == nil && body.Email == nil && body.Role == nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"patch must set at least one field"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-demotion guard: an admin/owner may edit their own email or username,
|
||||||
|
// but must never downgrade themselves to a lower role.
|
||||||
|
if body.Role != nil && id == p.UserID && *body.Role != p.Role {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
||||||
|
"cannot change your own role"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Username != nil {
|
||||||
|
if err := validateUsername(*body.Username); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Role != nil && *body.Role != "admin" && *body.Role != "user" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"role must be 'admin' or 'user', got %q", *body.Role))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := a.Repo.UpdateUser(r.Context(), id, UpdateUserInput{
|
||||||
|
Username: body.Username,
|
||||||
|
Email: body.Email,
|
||||||
|
Role: body.Role,
|
||||||
|
}, p.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "already_exists",
|
||||||
|
"username is already taken"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.patch", id)
|
||||||
|
writeJSON(w, http.StatusOK, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteUser is the admin-tier soft-delete endpoint (DELETE /users/{id}).
|
||||||
|
func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if id == p.UserID {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
||||||
|
"cannot delete your own account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Repo.DeleteUser(r.Context(), id, p.Email); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.delete", id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDisableUser is the admin-tier disable/enable toggle (POST /users/{id}/disable).
|
||||||
|
func (a *API) handleDisableUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if id == p.UserID {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
||||||
|
"cannot disable your own account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Repo.SetUserDisabled(r.Context(), id, body.Disabled); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := "user.enable"
|
||||||
|
if body.Disabled {
|
||||||
|
action = "user.disable"
|
||||||
|
}
|
||||||
|
a.audit(r, p.Email, action, id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "disabled": body.Disabled})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetPassword generates a high-entropy random password, stores its hash,
|
||||||
|
// forces must_change_password, and delivers the plaintext to the user's email
|
||||||
|
// (server-side log when no mailer is wired). The password is never returned to the
|
||||||
|
// admin caller — the response carries only the target email, not the password.
|
||||||
|
// (POST /users/{id}/reset-password). No request body — the server owns entropy.
|
||||||
|
func (a *API) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user to get their email.
|
||||||
|
u, err := a.Repo.UserByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := generateResetPassword()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Repo.AdminResetPassword(r.Context(), id, string(hash)); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.ResetMailer != nil && u.Email != "" {
|
||||||
|
if err := a.ResetMailer.SendPasswordReset(r.Context(), u.Email, password); err != nil {
|
||||||
|
log.Printf("reset-password: mail delivery failed for %s: %v", u.Email, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("reset-password: no ResetMailer configured; password for %s (%s): %s",
|
||||||
|
u.Username, id, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.reset_password", id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"email": u.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateResetPassword produces a 20-character, high-entropy random password
|
||||||
|
// drawn from alphanumerics plus a safe symbol set.
|
||||||
|
func generateResetPassword() (string, error) {
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_+=?"
|
||||||
|
const n = 20
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b[i] = chars[idx.Int64()]
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- quota admin ----
|
||||||
|
|
||||||
|
// handleGetQuotas is the admin-tier quotas read (GET /users/{id}/quotas).
|
||||||
|
func (a *API) handleGetQuotas(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := a.Repo.GetQuotas(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetQuotas is the admin-tier quotas write (PUT /users/{id}/quotas).
|
||||||
|
func (a *API) handleSetQuotas(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body QuotaInput
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject a body where every field is nil — a silent no-op is a client mistake.
|
||||||
|
if body.MaxServers == nil && body.MaxCPUMilli == nil && body.MaxMemoryMB == nil && body.MaxStorageGB == nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"at least one quota field must be set"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := a.Repo.SetQuotas(r.Context(), id, body, p.Email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.set_quotas", id)
|
||||||
|
writeJSON(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- session admin ----
|
||||||
|
|
||||||
|
// handleListUserSessions lists every live session for a user (GET /users/{id}/sessions).
|
||||||
|
func (a *API) handleListUserSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessions, err := a.Repo.ListUserSessions(r.Context(), id, a.now())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sessions == nil {
|
||||||
|
sessions = []SessionView{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeUserSessions revokes every live session of a user
|
||||||
|
// (DELETE /users/{id}/sessions).
|
||||||
|
func (a *API) handleRevokeUserSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Repo.RevokeAllUserSessions(r.Context(), id); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.revoke_sessions", id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeUserSession revokes a single session of a user
|
||||||
|
// (DELETE /users/{id}/sessions/{hash}).
|
||||||
|
func (a *API) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
id := r.PathValue("id")
|
||||||
|
tokenHash := r.PathValue("hash")
|
||||||
|
if id == "" || tokenHash == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Repo.RevokeSession(r.Context(), tokenHash); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.revoke_session", id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account-link admin ----
|
||||||
|
|
||||||
|
// handleUnlinkAccount removes a single (user_id, mc_uuid) binding
|
||||||
|
// (DELETE /users/{id}/links/{mc_uuid}).
|
||||||
|
func (a *API) handleUnlinkAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
userID := r.PathValue("id")
|
||||||
|
mcUUID := r.PathValue("mc_uuid")
|
||||||
|
if userID == "" || mcUUID == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Repo.UnlinkAccount(r.Context(), userID, mcUUID); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_found",
|
||||||
|
"no linked account for this UUID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.unlink_account", userID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "mc_uuid": mcUUID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLinkAccount force-binds a UUID to a user
|
||||||
|
// (POST /users/{id}/links).
|
||||||
|
func (a *API) handleLinkAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
userID := r.PathValue("id")
|
||||||
|
if userID == "" {
|
||||||
|
writeError(w, r, errBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
MCUUID string `json:"mc_uuid"`
|
||||||
|
AuthSource string `json:"auth_source"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.MCUUID == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"mc_uuid is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.AuthSource == "" {
|
||||||
|
body.AuthSource = "mojang"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.Repo.LinkAccount(r.Context(), userID, body.MCUUID, body.AuthSource); err != nil {
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "already_linked",
|
||||||
|
"this UUID is already linked to a different user"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.audit(r, p.Email, "user.link_account", userID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"mc_uuid": body.MCUUID,
|
||||||
|
"auth_source": body.AuthSource,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- validation ----
|
||||||
|
|
||||||
|
// validateUsername checks that name is a non-empty string of 1–32 characters
|
||||||
|
// consisting only of lowercase alphanumerics, hyphens, underscores, and dots,
|
||||||
|
// and without leading/trailing hyphens or consecutive dots.
|
||||||
|
func validateUsername(name string) error {
|
||||||
|
if len(name) == 0 || len(name) > 32 {
|
||||||
|
return newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"username must be 1–32 characters")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) != name {
|
||||||
|
return newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"username must not contain leading or trailing whitespace")
|
||||||
|
}
|
||||||
|
for _, c := range name {
|
||||||
|
switch {
|
||||||
|
case c >= 'a' && c <= 'z':
|
||||||
|
case c >= 'A' && c <= 'Z':
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
case c == '-', c == '_', c == '.':
|
||||||
|
default:
|
||||||
|
return newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"username contains invalid character %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ func (a *API) requireExternal(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// adminOnly gates an external-face handler on the admin Zero-Trust path. The
|
// adminOnly gates an external-face handler on the admin Zero-Trust path. The
|
||||||
// Access middleware has already authenticated; this enforces that admin-tier
|
// Access middleware has already authenticated; this enforces that admin-tier
|
||||||
// operations both carry role=admin and arrived via admin.* (spec §14).
|
// operations both carry role=admin AND arrived via admin.* (spec §14).
|
||||||
func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc {
|
func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if p := principalFromContext(r.Context()); !p.IsAdmin() {
|
if p := principalFromContext(r.Context()); !p.IsAdmin() {
|
||||||
@@ -107,6 +107,21 @@ func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerOnly gates a handler on the owner role — the single platform-level
|
||||||
|
// identity above admin. It is stricter than adminOnly: a plain admin with
|
||||||
|
// role=admin and valid admin Access path is still refused here. The owner
|
||||||
|
// arrives through the same admin Zero-Trust path, so adminOnly is not a
|
||||||
|
// prerequisite (the two guards are orthogonal).
|
||||||
|
func (a *API) ownerOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if p := principalFromContext(r.Context()); !p.IsOwner() {
|
||||||
|
writeError(w, r, errForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// lockdownDuringPasswordChange fences a staff principal that still owes a
|
// lockdownDuringPasswordChange fences a staff principal that still owes a
|
||||||
// first-login password change to the change-password surface (spec §B). It is the
|
// first-login password change to the change-password surface (spec §B). It is the
|
||||||
// default-deny half of the lockdown: buildFace wraps every authenticated route
|
// default-deny half of the lockdown: buildFace wraps every authenticated route
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
|||||||
switch {
|
switch {
|
||||||
case rt.Public:
|
case rt.Public:
|
||||||
tier = "public"
|
tier = "public"
|
||||||
|
case rt.Owner:
|
||||||
|
tier = "owner"
|
||||||
case rt.Admin:
|
case rt.Admin:
|
||||||
tier = "admin"
|
tier = "admin"
|
||||||
default:
|
default:
|
||||||
|
|||||||
+500
-9
@@ -711,25 +711,26 @@ func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpsertOwner creates or resets the Owner account direct-to-Postgres (the
|
// UpsertOwner creates or resets the Owner account direct-to-Postgres (the
|
||||||
// break-glass first-run / reset-password path). role is forced to 'admin'; on a
|
// break-glass first-run / reset-password path). role is forced to 'owner' —
|
||||||
// username conflict the email, hash and must_change_password flag are overwritten
|
// the platform-level identity one level above admin. On a username conflict the
|
||||||
// while the existing id is preserved, so live sessions referencing it survive a
|
// email, hash and must_change_password flag are overwritten while the existing
|
||||||
// password reset. The empty email is stored as NULL (users.email is nullable).
|
// id is preserved, so live sessions referencing it survive a password reset.
|
||||||
|
// The empty email is stored as NULL (users.email is nullable).
|
||||||
func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error {
|
||||||
_, err := p.db.ExecContext(ctx,
|
_, err := p.db.ExecContext(ctx,
|
||||||
`INSERT INTO users (id, username, email, role, password_hash, must_change_password)
|
`INSERT INTO users (id, username, email, role, password_hash, must_change_password)
|
||||||
VALUES ($1, $2, NULLIF($3, ''), 'admin', $4, $5)
|
VALUES ($1, $2, NULLIF($3, ''), 'owner', $4, $5)
|
||||||
ON CONFLICT (username) DO UPDATE SET
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
email = NULLIF($3, ''), role = 'admin',
|
email = NULLIF($3, ''), role = 'owner',
|
||||||
password_hash = $4, must_change_password = $5`,
|
password_hash = $4, must_change_password = $5`,
|
||||||
id, username, email, passwordHash, mustChange)
|
id, username, email, passwordHash, mustChange)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertOperator mints a NEW Operator (additional staff admin) account
|
// InsertOperator mints a NEW Operator (additional staff admin) account
|
||||||
// direct-to-Postgres. role is forced to 'admin' — Felis has no separate operator
|
// direct-to-Postgres. role is forced to 'admin' — Felis has a separate 'owner'
|
||||||
// role, so an Operator is an additional admin row identical in shape to the Owner
|
// role (migration 0011) for the single platform owner; Operators are below
|
||||||
// (migration 0003). UNLIKE UpsertOwner this is insert-only: a username conflict is
|
// that. UNLIKE UpsertOwner this is insert-only: a username conflict is
|
||||||
// left untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero
|
// left untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero
|
||||||
// RowsAffected, so adding an Operator can never silently reset the Owner's or
|
// RowsAffected, so adding an Operator can never silently reset the Owner's or
|
||||||
// another Operator's credential. The empty email is stored as NULL.
|
// another Operator's credential. The empty email is stored as NULL.
|
||||||
@@ -1008,3 +1009,493 @@ func (p *PGRepo) DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID
|
|||||||
`DELETE FROM webauthn_credentials WHERE user_id = $1`, userID)
|
`DELETE FROM webauthn_credentials WHERE user_id = $1`, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- user admin (spec §7, admin-only) ----
|
||||||
|
|
||||||
|
// ListUsers returns a page of non-deleted users matching the optional filters,
|
||||||
|
// newest first. total is the unfiltered count so the admin page can render
|
||||||
|
// pagination without a second round-trip.
|
||||||
|
func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView, int, error) {
|
||||||
|
var total int
|
||||||
|
{
|
||||||
|
q := `SELECT count(*) FROM users WHERE deleted_at IS NULL`
|
||||||
|
if err := p.db.QueryRowContext(ctx, q).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := opts.Limit
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
offset := opts.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the WHERE clause from filters. All args are positional so the order
|
||||||
|
// of appends must match.
|
||||||
|
where := ` WHERE u.deleted_at IS NULL`
|
||||||
|
var args []any
|
||||||
|
argn := 0
|
||||||
|
|
||||||
|
if opts.Query != "" {
|
||||||
|
argn++
|
||||||
|
where += fmt.Sprintf(` AND (u.username ILIKE '%%' || $%d || '%%' OR u.email ILIKE '%%' || $%d || '%%')`, argn, argn)
|
||||||
|
args = append(args, opts.Query)
|
||||||
|
}
|
||||||
|
if opts.Role != "" {
|
||||||
|
argn++
|
||||||
|
where += fmt.Sprintf(` AND u.role::text = $%d`, argn)
|
||||||
|
args = append(args, opts.Role)
|
||||||
|
}
|
||||||
|
switch opts.Hidden {
|
||||||
|
case "true":
|
||||||
|
where += ` AND u.disabled = true`
|
||||||
|
case "false":
|
||||||
|
where += ` AND u.disabled = false`
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text,
|
||||||
|
u.disabled, u.email_verified, u.must_change_password,
|
||||||
|
u.created_at, u.updated_at,
|
||||||
|
COALESCE((SELECT count(*) FROM servers s WHERE s.owner_id = u.id AND s.deleted_at IS NULL), 0)
|
||||||
|
FROM users u` + where
|
||||||
|
argn++
|
||||||
|
q += fmt.Sprintf(` ORDER BY u.created_at DESC LIMIT $%d OFFSET $%d`, argn, argn+1)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := p.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []UserView
|
||||||
|
for rows.Next() {
|
||||||
|
var v UserView
|
||||||
|
if err := rows.Scan(&v.ID, &v.Username, &v.Email, &v.Role,
|
||||||
|
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
|
||||||
|
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetail loads one user with its linked MC accounts, or ErrNotFound.
|
||||||
|
func (p *PGRepo) UserDetail(ctx context.Context, userID string) (*UserDetail, error) {
|
||||||
|
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text,
|
||||||
|
u.disabled, u.email_verified, u.must_change_password,
|
||||||
|
u.created_at, u.updated_at, u.deleted_at,
|
||||||
|
COALESCE((SELECT count(*) FROM servers s WHERE s.owner_id = u.id AND s.deleted_at IS NULL), 0)
|
||||||
|
FROM users u WHERE u.id = $1`
|
||||||
|
var d UserDetail
|
||||||
|
switch err := p.db.QueryRowContext(ctx, q, userID).Scan(
|
||||||
|
&d.ID, &d.Username, &d.Email, &d.Role,
|
||||||
|
&d.Disabled, &d.EmailVerified, &d.MustChangePassword,
|
||||||
|
&d.CreatedAt, &d.UpdatedAt, &d.DeletedAt, &d.ServerCount); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, ErrNotFound
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load linked MC accounts.
|
||||||
|
linkRows, err := p.db.QueryContext(ctx,
|
||||||
|
`SELECT mc_uuid::text, COALESCE(auth_source, 'mojang'), verified_at
|
||||||
|
FROM account_links WHERE user_id = $1 ORDER BY verified_at`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return &d, nil // best-effort; linked accounts are informational
|
||||||
|
}
|
||||||
|
defer linkRows.Close()
|
||||||
|
for linkRows.Next() {
|
||||||
|
var a LinkedAccount
|
||||||
|
if err := linkRows.Scan(&a.MCUUID, &a.AuthSource, &a.VerifiedAt); err != nil {
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
d.LinkedAccounts = append(d.LinkedAccounts, a)
|
||||||
|
}
|
||||||
|
return &d, linkRows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser mints a new user row with an initial password hash. A username
|
||||||
|
// conflict → ErrConflict.
|
||||||
|
func (p *PGRepo) CreateUser(ctx context.Context, input CreateUserInput, _ string) (*UserView, error) {
|
||||||
|
const q = `INSERT INTO users (id, username, email, role, password_hash, must_change_password)
|
||||||
|
VALUES (gen_random_uuid()::text, $1, NULLIF($2, ''), $3::user_role, $4, $5)
|
||||||
|
ON CONFLICT (username) DO NOTHING
|
||||||
|
RETURNING id, username, COALESCE(email, ''), role::text, disabled, email_verified,
|
||||||
|
must_change_password, created_at, updated_at, 0`
|
||||||
|
var v UserView
|
||||||
|
switch err := p.db.QueryRowContext(ctx, q,
|
||||||
|
input.Username, input.Email, input.Role, input.PasswordHash, input.MustChange).Scan(
|
||||||
|
&v.ID, &v.Username, &v.Email, &v.Role,
|
||||||
|
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
|
||||||
|
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, ErrConflict
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser applies the non-nil fields of patch and returns the updated view.
|
||||||
|
// A username conflict → ErrConflict; a non-existent user → ErrNotFound.
|
||||||
|
func (p *PGRepo) UpdateUser(ctx context.Context, userID string, patch UpdateUserInput, _ string) (*UserView, error) {
|
||||||
|
// Build a dynamic SET clause from non-nil patch fields.
|
||||||
|
var sets []string
|
||||||
|
var args []any
|
||||||
|
argn := 0
|
||||||
|
if patch.Username != nil {
|
||||||
|
argn++
|
||||||
|
sets = append(sets, fmt.Sprintf("username = $%d", argn))
|
||||||
|
args = append(args, *patch.Username)
|
||||||
|
}
|
||||||
|
if patch.Email != nil {
|
||||||
|
argn++
|
||||||
|
sets = append(sets, fmt.Sprintf("email = NULLIF($%d, '')", argn))
|
||||||
|
args = append(args, *patch.Email)
|
||||||
|
}
|
||||||
|
if patch.Role != nil {
|
||||||
|
argn++
|
||||||
|
sets = append(sets, fmt.Sprintf("role = $%d::user_role", argn))
|
||||||
|
args = append(args, *patch.Role)
|
||||||
|
}
|
||||||
|
if len(sets) == 0 {
|
||||||
|
// No fields to update; return the current view.
|
||||||
|
v, err := p.userView(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
argn++
|
||||||
|
args = append(args, userID)
|
||||||
|
|
||||||
|
q := `UPDATE users SET ` + fmt.Sprintf("%s", sets[0])
|
||||||
|
for _, s := range sets[1:] {
|
||||||
|
q += ", " + s
|
||||||
|
}
|
||||||
|
q += fmt.Sprintf(` WHERE id = $%d AND deleted_at IS NULL`, argn)
|
||||||
|
q += ` RETURNING id, username, COALESCE(email, ''), role::text, disabled,
|
||||||
|
email_verified, must_change_password, created_at, updated_at,
|
||||||
|
(SELECT count(*) FROM servers WHERE owner_id = users.id AND deleted_at IS NULL)`
|
||||||
|
var v UserView
|
||||||
|
switch err := p.db.QueryRowContext(ctx, q, args...).Scan(
|
||||||
|
&v.ID, &v.Username, &v.Email, &v.Role,
|
||||||
|
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
|
||||||
|
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, ErrNotFound
|
||||||
|
case err != nil:
|
||||||
|
// A username UNIQUE violation surfaces as a driver error; map it to
|
||||||
|
// ErrConflict so the handler can answer 409.
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return nil, ErrConflict
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userView returns a live user's projection, or ErrNotFound. It is the read half
|
||||||
|
// shared by UpdateUser (no-op return) and several other paths.
|
||||||
|
func (p *PGRepo) userView(ctx context.Context, userID string) (*UserView, error) {
|
||||||
|
const q = `SELECT id, username, COALESCE(email, ''), role::text, disabled,
|
||||||
|
email_verified, must_change_password, created_at, updated_at,
|
||||||
|
(SELECT count(*) FROM servers WHERE owner_id = users.id AND deleted_at IS NULL)
|
||||||
|
FROM users WHERE id = $1 AND deleted_at IS NULL`
|
||||||
|
var v UserView
|
||||||
|
switch err := p.db.QueryRowContext(ctx, q, userID).Scan(
|
||||||
|
&v.ID, &v.Username, &v.Email, &v.Role,
|
||||||
|
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
|
||||||
|
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, ErrNotFound
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser soft-deletes a user in one transaction: sets deleted_at, revokes
|
||||||
|
// every live session, and releases every owned server. The row is preserved so
|
||||||
|
// audit_logs.actor references survive.
|
||||||
|
func (p *PGRepo) DeleteUser(ctx context.Context, userID, _ string) error {
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Verify the user exists and is not already deleted.
|
||||||
|
var exists bool
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL)`,
|
||||||
|
userID).Scan(&exists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release all owned servers.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE servers SET owner_id = NULL WHERE owner_id = $1 AND deleted_at IS NULL`,
|
||||||
|
userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke every live session.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete the user row.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE users SET disabled = true, deleted_at = now() WHERE id = $1`,
|
||||||
|
userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserDisabled flips the disabled flag. Setting disabled→true additionally
|
||||||
|
// revokes every live session so the account is immediately locked out.
|
||||||
|
func (p *PGRepo) SetUserDisabled(ctx context.Context, userID string, disabled bool) error {
|
||||||
|
// Guard: the user must exist and not be deleted.
|
||||||
|
var ok bool
|
||||||
|
if err := p.db.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL)`,
|
||||||
|
userID).Scan(&ok); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := p.db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET disabled = $2 WHERE id = $1`, userID, disabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
// Revoke every live session so the lockout is immediate.
|
||||||
|
_, _ = p.db.ExecContext(ctx,
|
||||||
|
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminResetPassword stores a new hash and forces must_change_password so the
|
||||||
|
// admin-set password is replaced on first login.
|
||||||
|
func (p *PGRepo) AdminResetPassword(ctx context.Context, userID, passwordHash string) error {
|
||||||
|
res, err := p.db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET password_hash = $2, must_change_password = true WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
userID, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
// Revoke every session so the old password cannot be used via a retained cookie.
|
||||||
|
_, _ = p.db.ExecContext(ctx,
|
||||||
|
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- quota admin ----
|
||||||
|
|
||||||
|
// GetQuotas returns the quotas row for a user, or a zero-value view when no
|
||||||
|
// row exists (meaning unlimited).
|
||||||
|
func (p *PGRepo) GetQuotas(ctx context.Context, userID string) (*QuotaView, error) {
|
||||||
|
const q = `SELECT user_id, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb
|
||||||
|
FROM quotas WHERE user_id = $1`
|
||||||
|
v := QuotaView{UserID: userID}
|
||||||
|
switch err := p.db.QueryRowContext(ctx, q, userID).Scan(
|
||||||
|
&v.UserID, &v.MaxServers, &v.MaxCPUMilli, &v.MaxMemoryMB, &v.MaxStorageGB); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return &v, nil
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQuotas upserts a quotas row. Nil fields are left unchanged; a non-nil
|
||||||
|
// zero-value field clears the cap.
|
||||||
|
func (p *PGRepo) SetQuotas(ctx context.Context, userID string, qi QuotaInput, setBy string) (*QuotaView, error) {
|
||||||
|
type col struct {
|
||||||
|
name string
|
||||||
|
value *int
|
||||||
|
}
|
||||||
|
cols := []col{
|
||||||
|
{"max_servers", qi.MaxServers},
|
||||||
|
{"max_cpu_milli", qi.MaxCPUMilli},
|
||||||
|
{"max_memory_mb", qi.MaxMemoryMB},
|
||||||
|
{"max_storage_gb", qi.MaxStorageGB},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the ON CONFLICT upsert dynamically.
|
||||||
|
var insCols, insVals []string
|
||||||
|
var upd []string
|
||||||
|
var args []any
|
||||||
|
argn := 0
|
||||||
|
args = append(args, userID) // $1 = user_id
|
||||||
|
argn++
|
||||||
|
args = append(args, setBy) // $2 = updated_by
|
||||||
|
argn++
|
||||||
|
insCols = append(insCols, "user_id", "updated_by")
|
||||||
|
insVals = append(insVals, "$1", "$2")
|
||||||
|
|
||||||
|
for _, c := range cols {
|
||||||
|
if c.value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
argn++
|
||||||
|
insCols = append(insCols, c.name)
|
||||||
|
insVals = append(insVals, fmt.Sprintf("$%d", argn))
|
||||||
|
args = append(args, *c.value)
|
||||||
|
upd = append(upd, fmt.Sprintf("%s = EXCLUDED.%s", c.name, c.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`INSERT INTO quotas (%s) VALUES (%s)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET %s, updated_by = $2
|
||||||
|
RETURNING user_id, max_servers, max_cpu_milli, max_memory_mb, max_storage_gb`,
|
||||||
|
joinStr(insCols), joinStr(insVals), joinStr(upd))
|
||||||
|
|
||||||
|
v := QuotaView{}
|
||||||
|
switch err := p.db.QueryRowContext(ctx, query, args...).Scan(
|
||||||
|
&v.UserID, &v.MaxServers, &v.MaxCPUMilli, &v.MaxMemoryMB, &v.MaxStorageGB); {
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- session admin ----
|
||||||
|
|
||||||
|
// ListUserSessions returns every live session for a user, newest first.
|
||||||
|
func (p *PGRepo) ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error) {
|
||||||
|
const q = `SELECT token_hash, created_at, expires_at, revoked_at
|
||||||
|
FROM sessions WHERE user_id = $1 AND (revoked_at IS NULL OR revoked_at > $2) AND expires_at > $2
|
||||||
|
ORDER BY created_at DESC`
|
||||||
|
rows, err := p.db.QueryContext(ctx, q, userID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []SessionView
|
||||||
|
for rows.Next() {
|
||||||
|
var s SessionView
|
||||||
|
if err := rows.Scan(&s.TokenHash, &s.CreatedAt, &s.ExpiresAt, &s.RevokedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllUserSessions marks every live session of userID revoked.
|
||||||
|
func (p *PGRepo) RevokeAllUserSessions(ctx context.Context, userID string) error {
|
||||||
|
_, err := p.db.ExecContext(ctx,
|
||||||
|
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account-link admin ----
|
||||||
|
|
||||||
|
// UnlinkAccount removes a single (user_id, mc_uuid) binding.
|
||||||
|
func (p *PGRepo) UnlinkAccount(ctx context.Context, userID, mcUUID string) error {
|
||||||
|
res, err := p.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM account_links WHERE user_id = $1 AND mc_uuid = $2`,
|
||||||
|
userID, mcUUID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkAccount force-binds a UUID to a user. A UUID already linked to a different
|
||||||
|
// user → ErrConflict; same (user, uuid) pair is idempotent (ON CONFLICT DO
|
||||||
|
// NOTHING on the UNIQUE(mc_uuid) constraint, plus an idempotency check via
|
||||||
|
// EXISTS).
|
||||||
|
func (p *PGRepo) LinkAccount(ctx context.Context, userID, mcUUID, authSource string) error {
|
||||||
|
// Check idempotency first: already linked to this user → success.
|
||||||
|
var exists bool
|
||||||
|
if err := p.db.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM account_links WHERE user_id = $1 AND mc_uuid = $2)`,
|
||||||
|
userID, mcUUID).Scan(&exists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try insert. The UNIQUE(mc_uuid) constraint will reject a UUID already
|
||||||
|
// bound to a different user.
|
||||||
|
res, err := p.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO account_links (user_id, mc_uuid, auth_source, verified_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (mc_uuid) DO NOTHING`,
|
||||||
|
userID, mcUUID, authSource)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinStr joins a slice of strings with ", ".
|
||||||
|
func joinStr(vals []string) string {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := vals[0]
|
||||||
|
for _, v := range vals[1:] {
|
||||||
|
s += ", " + v
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUniqueViolation reports whether err is a Postgres unique-constraint
|
||||||
|
// violation (code 23505).
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
return stringsContains(err.Error(), "duplicate key") || stringsContains(err.Error(), "23505")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringsContains(s, sub string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -382,4 +382,151 @@ type Repo interface {
|
|||||||
GetSetting(ctx context.Context, key string) ([]byte, error)
|
GetSetting(ctx context.Context, key string) ([]byte, error)
|
||||||
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
||||||
SetSetting(ctx context.Context, key string, value []byte) error
|
SetSetting(ctx context.Context, key string, value []byte) error
|
||||||
|
|
||||||
|
// ---- user admin (spec §7, admin-only) ----
|
||||||
|
|
||||||
|
// ListUsers returns a page of non-deleted users matching the optional filters,
|
||||||
|
// newest first. total is the unfiltered count so the admin page can render
|
||||||
|
// pagination without a second round-trip.
|
||||||
|
ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView, int, error)
|
||||||
|
// UserDetail loads one user with its linked MC accounts, or ErrNotFound.
|
||||||
|
// A deleted user is returned (the row lives for audit) but flagged.
|
||||||
|
UserDetail(ctx context.Context, userID string) (*UserDetail, error)
|
||||||
|
// CreateUser mints a new user row (role forced to either 'admin' or 'user')
|
||||||
|
// with an initial password hash. createdBy is the actor email for audit. A
|
||||||
|
// username conflict → ErrConflict.
|
||||||
|
CreateUser(ctx context.Context, input CreateUserInput, createdBy string) (*UserView, error)
|
||||||
|
// UpdateUser applies the non-nil fields of patch to the user identified by
|
||||||
|
// userID and returns the updated view. A username conflict → ErrConflict;
|
||||||
|
// a non-existent user → ErrNotFound. updatedBy is the actor email.
|
||||||
|
UpdateUser(ctx context.Context, userID string, patch UpdateUserInput, updatedBy string) (*UserView, error)
|
||||||
|
// DeleteUser soft-deletes the user: sets deleted_at, revokes every live
|
||||||
|
// session, and releases every owned server (owner_id → NULL). deletedBy is
|
||||||
|
// the actor email. A non-existent or already-deleted user → ErrNotFound.
|
||||||
|
// The row is preserved so audit_logs.actor references survive.
|
||||||
|
DeleteUser(ctx context.Context, userID, deletedBy string) error
|
||||||
|
// SetUserDisabled flips the disabled flag on a live (non-deleted) user.
|
||||||
|
// Setting disabled→true additionally revokes every live session so a
|
||||||
|
// disabled account is immediately locked out. A non-existent user →
|
||||||
|
// ErrNotFound; a deleted user → ErrNotFound.
|
||||||
|
SetUserDisabled(ctx context.Context, userID string, disabled bool) error
|
||||||
|
// AdminResetPassword stores a new bcrypt hash for a user and forces
|
||||||
|
// must_change_password, so the admin-set password is replaced on first
|
||||||
|
// login. ErrNotFound when no live row matches.
|
||||||
|
AdminResetPassword(ctx context.Context, userID, passwordHash string) error
|
||||||
|
|
||||||
|
// ---- quota admin (spec §6 quotas, admin-only) ----
|
||||||
|
|
||||||
|
// GetQuotas returns the quotas row for a user, or a zero-value view when no
|
||||||
|
// row exists (which means unlimited per spec §9.3).
|
||||||
|
GetQuotas(ctx context.Context, userID string) (*QuotaView, error)
|
||||||
|
// SetQuotas upserts a quotas row for userID. Nil fields leave the column
|
||||||
|
// untouched; a zero-value (non-nil) field clears the cap (unlimited).
|
||||||
|
SetQuotas(ctx context.Context, userID string, q QuotaInput, setBy string) (*QuotaView, error)
|
||||||
|
|
||||||
|
// ---- session admin (admin-only) ----
|
||||||
|
|
||||||
|
// ListUserSessions returns every live (unrevoked, unexpired at now) session
|
||||||
|
// for a user, newest first. An empty list is not an error.
|
||||||
|
ListUserSessions(ctx context.Context, userID string, now time.Time) ([]SessionView, error)
|
||||||
|
// RevokeAllUserSessions marks every live session of userID revoked.
|
||||||
|
// Revoking zero sessions is not an error.
|
||||||
|
RevokeAllUserSessions(ctx context.Context, userID string) error
|
||||||
|
|
||||||
|
// ---- account-link admin (admin-only) ----
|
||||||
|
|
||||||
|
// UnlinkAccount removes a single (user_id, mc_uuid) binding. It does not
|
||||||
|
// consume the UUID's link code — a re-link by the player later is still
|
||||||
|
// possible — but the admin can unlink without going through the player.
|
||||||
|
// A non-existent binding → ErrNotFound.
|
||||||
|
UnlinkAccount(ctx context.Context, userID, mcUUID string) error
|
||||||
|
// LinkAccount force-binds a verified MC UUID to a user, bypassing the
|
||||||
|
// normal code-verification flow. The UUID must not already be linked to a
|
||||||
|
// different user (→ ErrConflict). A duplicate bind of the same pair is
|
||||||
|
// idempotent. authSource records which Yggdrasil established the UUID
|
||||||
|
// (mojang | thirdparty, spec §10 dual-Yggdrasil).
|
||||||
|
LinkAccount(ctx context.Context, userID, mcUUID, authSource string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- user admin types ----
|
||||||
|
|
||||||
|
// ListUsersOpts carries the optional filters and pagination for ListUsers.
|
||||||
|
// Zero values mean "no filter / default page."
|
||||||
|
type ListUsersOpts struct {
|
||||||
|
Query string // substring match on username or email
|
||||||
|
Role string // exact role match ("admin" / "user"), or "" for all
|
||||||
|
Hidden string // "true" = disabled only, "false" = enabled only, "" = all
|
||||||
|
Limit int // page size; 0 → default 20
|
||||||
|
Offset int // page offset; 0 → first page
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserView is one row of the admin user list.
|
||||||
|
type UserView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
MustChangePassword bool `json:"must_change_password"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetail is the full admin view of one user, including linked MC accounts.
|
||||||
|
type UserDetail struct {
|
||||||
|
UserView
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
LinkedAccounts []LinkedAccount `json:"linked_accounts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedAccount is one verified MC-UUID binding (account_links, spec §10).
|
||||||
|
type LinkedAccount struct {
|
||||||
|
MCUUID string `json:"mc_uuid"`
|
||||||
|
AuthSource string `json:"auth_source"`
|
||||||
|
VerifiedAt time.Time `json:"verified_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserInput is the admin create-user form.
|
||||||
|
type CreateUserInput struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
MustChange bool `json:"must_change_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserInput is the admin patch-user form. Every field is a pointer so
|
||||||
|
// an absent field ("leave unchanged") is distinguishable from a zero value.
|
||||||
|
type UpdateUserInput struct {
|
||||||
|
Username *string `json:"username,omitempty"`
|
||||||
|
Email *string `json:"email,omitempty"`
|
||||||
|
Role *string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaView is the admin-visible quotas row (spec §6).
|
||||||
|
type QuotaView struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
MaxServers *int `json:"max_servers,omitempty"`
|
||||||
|
MaxCPUMilli *int `json:"max_cpu_milli,omitempty"`
|
||||||
|
MaxMemoryMB *int `json:"max_memory_mb,omitempty"`
|
||||||
|
MaxStorageGB *int `json:"max_storage_gb,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaInput is the admin set-quotas form. Nil fields are left unchanged;
|
||||||
|
// a non-nil zero-value field clears the cap (unlimited).
|
||||||
|
type QuotaInput struct {
|
||||||
|
MaxServers *int `json:"max_servers,omitempty"`
|
||||||
|
MaxCPUMilli *int `json:"max_cpu_milli,omitempty"`
|
||||||
|
MaxMemoryMB *int `json:"max_memory_mb,omitempty"`
|
||||||
|
MaxStorageGB *int `json:"max_storage_gb,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionView is one live session row visible to an admin.
|
||||||
|
type SessionView struct {
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- User management hardening: production-grade admin CRUD (spec §7 user admin).
|
||||||
|
-- Adds soft delete, disable toggle, audit timestamps, and an auto-updating
|
||||||
|
-- timestamp trigger so the admin user list reflects the last mutation without
|
||||||
|
-- every query re-deriving it from audit_logs.
|
||||||
|
|
||||||
|
-- Per-row lifecycle markers on the users table.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN disabled boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN deleted_at timestamptz,
|
||||||
|
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
-- updated_at auto-trigger, shared by any table that carries the column.
|
||||||
|
CREATE OR REPLACE FUNCTION felis_set_updated_at()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_users_updated_at
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION felis_set_updated_at();
|
||||||
|
|
||||||
|
-- quotas gains its own audit timestamp so an admin change (including who made it)
|
||||||
|
-- is visible downstream.
|
||||||
|
ALTER TABLE quotas
|
||||||
|
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN updated_by text;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_quotas_updated_at
|
||||||
|
BEFORE UPDATE ON quotas
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION felis_set_updated_at();
|
||||||
|
|
||||||
|
-- Efficient lookups for the admin user list: filter by role and exclude
|
||||||
|
-- soft-deleted rows in one pass.
|
||||||
|
CREATE INDEX idx_users_role_active ON users (role)
|
||||||
|
WHERE deleted_at IS NULL AND disabled = false;
|
||||||
|
CREATE INDEX idx_users_deleted ON users (deleted_at)
|
||||||
|
WHERE deleted_at IS NOT NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Owner role: a platform-level identity above admin. Only an owner may manage
|
||||||
|
-- users (create / edit / disable / delete / reset password / manage quotas and
|
||||||
|
-- sessions). The role is added at the end of the enum so it sorts after 'user'
|
||||||
|
-- and 'admin' — safe for existing data and type comparisons.
|
||||||
|
--
|
||||||
|
-- The Owner account is minted by `felis breakGlass` at first-run bootstrap;
|
||||||
|
-- additional Operators created later are role='admin'. The Owner is the one
|
||||||
|
-- identity that can never be demoted or deleted through the panel — only
|
||||||
|
-- another owner (the break-glass console) may reset a lost owner.
|
||||||
|
--
|
||||||
|
-- UPGRADE NOTE: after applying this migration, your existing first admin
|
||||||
|
-- account is still role='admin'. Re-provision it via `felis breakGlass` (the
|
||||||
|
-- Owner path) to promote it to role='owner'. That path is idempotent — it
|
||||||
|
-- preserves the existing user id and resets the credential, now with the owner
|
||||||
|
-- role. Alternatively, run directly:
|
||||||
|
-- UPDATE users SET role = 'owner' WHERE id = '<your-owner-user-id>';
|
||||||
|
|
||||||
|
ALTER TYPE user_role ADD VALUE 'owner';
|
||||||
+368
-33
@@ -12,26 +12,44 @@ import type {
|
|||||||
ServerInfo,
|
ServerInfo,
|
||||||
WhitelistImage,
|
WhitelistImage,
|
||||||
Submission,
|
Submission,
|
||||||
|
UserView,
|
||||||
|
UserDetail,
|
||||||
|
CreateUserRequest,
|
||||||
|
PatchUserRequest,
|
||||||
|
QuotaView,
|
||||||
|
QuotaInput,
|
||||||
|
SessionView,
|
||||||
} from "../src/lib/types";
|
} from "../src/lib/types";
|
||||||
|
|
||||||
const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
|
type AccountID = string;
|
||||||
|
type Role = "admin" | "user" | "owner";
|
||||||
type AccountID = (typeof ACCOUNT_IDS)[number];
|
type Method = "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||||
type Role = "admin" | "user";
|
|
||||||
type Method = "GET" | "POST" | "DELETE";
|
|
||||||
type CreateError =
|
type CreateError =
|
||||||
| "bad_request"
|
| "bad_request"
|
||||||
| "already_exists"
|
| "already_exists"
|
||||||
| "subdomain_taken"
|
| "subdomain_taken"
|
||||||
| "image_not_whitelisted";
|
| "image_not_whitelisted";
|
||||||
|
|
||||||
|
function isAdmin(role: Role): boolean {
|
||||||
|
return role === "admin" || role === "owner";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwner(role: Role): boolean {
|
||||||
|
return role === "owner";
|
||||||
|
}
|
||||||
|
|
||||||
interface MockAccount {
|
interface MockAccount {
|
||||||
id: AccountID;
|
id: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
email: string;
|
email: string;
|
||||||
linked: boolean;
|
linked: boolean;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
quota?: QuotaView;
|
||||||
|
sessions?: SessionView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockServer extends ServerInfo {
|
interface MockServer extends ServerInfo {
|
||||||
@@ -180,7 +198,7 @@ function mockBackups(): BackupView[] {
|
|||||||
function initialState(): MockState {
|
function initialState(): MockState {
|
||||||
return {
|
return {
|
||||||
accounts: {
|
accounts: {
|
||||||
owner: account("owner", "admin", true, false, false),
|
owner: account("owner", "owner", true, false, false),
|
||||||
user: account("user", "user", false, false, false),
|
user: account("user", "user", false, false, false),
|
||||||
linked: account("linked", "user", true, false, true),
|
linked: account("linked", "user", true, false, true),
|
||||||
setup: account("setup", "admin", true, true, false),
|
setup: account("setup", "admin", true, true, false),
|
||||||
@@ -483,14 +501,13 @@ async function readJSON<T>(req: IncomingMessage): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readAccount(req: IncomingMessage, state: MockState): MockAccount | null {
|
function readAccount(req: IncomingMessage, state: MockState): MockAccount | null {
|
||||||
const ids = ACCOUNT_IDS.join("|");
|
const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([a-zA-Z0-9_-]+)(?:;|$)`).exec(
|
||||||
const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=(${ids})(?:;|$)`).exec(
|
|
||||||
req.headers.cookie ?? "",
|
req.headers.cookie ?? "",
|
||||||
);
|
);
|
||||||
return m ? state.accounts[m[1] as AccountID] : null;
|
return m ? state.accounts[m[1]] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSessionCookie(res: ServerResponse, accountID: AccountID): void {
|
function setSessionCookie(res: ServerResponse, accountID: string): void {
|
||||||
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
|
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,9 +515,13 @@ function clearSessionCookie(res: ServerResponse): void {
|
|||||||
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
|
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginAccount(username: string): AccountID | null {
|
function loginAccount(username: string, state: MockState): string | null {
|
||||||
const normalized = username.toLowerCase();
|
const normalized = username.toLowerCase();
|
||||||
return ACCOUNT_IDS.includes(normalized as AccountID) ? (normalized as AccountID) : null;
|
const acc = state.accounts[normalized];
|
||||||
|
if (acc && !acc.disabled) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function identity(accountInfo: MockAccount): Identity {
|
function identity(accountInfo: MockAccount): Identity {
|
||||||
@@ -508,7 +529,8 @@ function identity(accountInfo: MockAccount): Identity {
|
|||||||
user_id: `mock-${accountInfo.id}`,
|
user_id: `mock-${accountInfo.id}`,
|
||||||
email: accountInfo.email,
|
email: accountInfo.email,
|
||||||
role: accountInfo.role,
|
role: accountInfo.role,
|
||||||
is_admin: accountInfo.role === "admin",
|
is_admin: isAdmin(accountInfo.role),
|
||||||
|
is_owner: isOwner(accountInfo.role),
|
||||||
must_change_password: accountInfo.mustChangePassword,
|
must_change_password: accountInfo.mustChangePassword,
|
||||||
email_verified: accountInfo.emailVerified,
|
email_verified: accountInfo.emailVerified,
|
||||||
};
|
};
|
||||||
@@ -519,11 +541,11 @@ function findServer(state: MockState, name: string): MockServer | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canSee(accountInfo: MockAccount, serverInfo: MockServer): boolean {
|
function canSee(accountInfo: MockAccount, serverInfo: MockServer): boolean {
|
||||||
return accountInfo.role === "admin" || serverInfo.owner === accountInfo.id || serverInfo.owner === null;
|
return isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id || serverInfo.owner === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
|
function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
|
||||||
return accountInfo.role === "admin" || serverInfo.owner === accountInfo.id;
|
return isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[] {
|
function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[] {
|
||||||
@@ -610,7 +632,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
switch (route(ctx)) {
|
switch (route(ctx)) {
|
||||||
case "POST auth/login": {
|
case "POST auth/login": {
|
||||||
const body = await readJSON<{ username?: string; password?: string }>(ctx.req);
|
const body = await readJSON<{ username?: string; password?: string }>(ctx.req);
|
||||||
const accountID = body.username ? loginAccount(body.username.trim()) : null;
|
const accountID = body.username ? loginAccount(body.username.trim(), ctx.state) : null;
|
||||||
if (!accountID || body.password !== MOCK_PASSWORD) {
|
if (!accountID || body.password !== MOCK_PASSWORD) {
|
||||||
sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials");
|
sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials");
|
||||||
return true;
|
return true;
|
||||||
@@ -657,14 +679,14 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||||
switch (route(ctx)) {
|
switch (route(ctx)) {
|
||||||
case "GET updates/window":
|
case "GET updates/window":
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
sendJSON(ctx.res, 200, ctx.state.updateWindow);
|
sendJSON(ctx.res, 200, ctx.state.updateWindow);
|
||||||
return true;
|
return true;
|
||||||
case "PUT updates/window": {
|
case "PUT updates/window": {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -699,7 +721,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
case "GET fleet":
|
case "GET fleet":
|
||||||
// Admin-tier, fleet-wide — mirrors the real adminOnly gate (a non-admin is
|
// Admin-tier, fleet-wide — mirrors the real adminOnly gate (a non-admin is
|
||||||
// 403'd before the handler) so the cockpit's RequireAdmin path is exercised.
|
// 403'd before the handler) so the cockpit's RequireAdmin path is exercised.
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -714,7 +736,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
|
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
|
||||||
sendJSON(ctx.res, 200, {
|
sendJSON(ctx.res, 200, {
|
||||||
backups: ctx.state.backups.filter(
|
backups: ctx.state.backups.filter(
|
||||||
(b) => ctx.account.role === "admin" || b.former_owner === ctx.account.id,
|
(b) => isAdmin(ctx.account.role) || b.former_owner === ctx.account.id,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -795,12 +817,325 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
ctx.res.end();
|
ctx.res.end();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (await handleUserRoute(ctx)) return true;
|
||||||
if (await handleImageRoute(ctx)) return true;
|
if (await handleImageRoute(ctx)) return true;
|
||||||
if (await handleSubmissionRoute(ctx)) return true;
|
if (await handleSubmissionRoute(ctx)) return true;
|
||||||
return await handleServerRoute(ctx);
|
return await handleServerRoute(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
||||||
|
if (ctx.parts[2] !== "users") return false;
|
||||||
|
|
||||||
|
// Owner access check for user management routes
|
||||||
|
if (!isOwner(ctx.account.role)) {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "owner account required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdOrAction = ctx.parts[3];
|
||||||
|
|
||||||
|
// GET /api/v1/users
|
||||||
|
if (is("GET", ctx) && !userIdOrAction) {
|
||||||
|
const url = new URL(ctx.req.url ?? "/", "http://localhost");
|
||||||
|
const search = url.searchParams.get("search")?.toLowerCase() || "";
|
||||||
|
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||||
|
const limit = parseInt(url.searchParams.get("limit") || "10", 10);
|
||||||
|
|
||||||
|
const allUsers = Object.values(ctx.state.accounts).map((acc) => {
|
||||||
|
// count active/owned servers
|
||||||
|
const serverCount = ctx.state.servers.filter((s) => s.owner === acc.id).length;
|
||||||
|
return {
|
||||||
|
id: `mock-${acc.id}`,
|
||||||
|
username: acc.id,
|
||||||
|
email: acc.email,
|
||||||
|
role: acc.role,
|
||||||
|
disabled: !!acc.disabled,
|
||||||
|
email_verified: acc.emailVerified,
|
||||||
|
server_count: serverCount,
|
||||||
|
must_change_password: acc.mustChangePassword,
|
||||||
|
created_at: acc.created_at || new Date().toISOString(),
|
||||||
|
updated_at: acc.updated_at || new Date().toISOString(),
|
||||||
|
} as UserView;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = allUsers.filter((u) => {
|
||||||
|
return (
|
||||||
|
u.username.toLowerCase().includes(search) ||
|
||||||
|
u.email.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginated = filtered.slice((page - 1) * limit, page * limit);
|
||||||
|
|
||||||
|
sendJSON(ctx.res, 200, {
|
||||||
|
users: paginated,
|
||||||
|
total: filtered.length,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/users
|
||||||
|
if (is("POST", ctx) && !userIdOrAction) {
|
||||||
|
const body = await readJSON<CreateUserRequest>(ctx.req);
|
||||||
|
const username = body.username?.trim().toLowerCase();
|
||||||
|
if (!username) {
|
||||||
|
sendError(ctx.res, 400, "bad_request", "username is required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ctx.state.accounts[username]) {
|
||||||
|
sendError(ctx.res, 409, "already_exists", "username is already taken");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAcc: MockAccount = {
|
||||||
|
id: username,
|
||||||
|
role: body.role || "user",
|
||||||
|
email: body.email || `${username}@example.com`,
|
||||||
|
linked: false,
|
||||||
|
mustChangePassword: body.must_change_password ?? false,
|
||||||
|
emailVerified: true,
|
||||||
|
disabled: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
quota: {
|
||||||
|
user_id: `mock-${username}`,
|
||||||
|
max_servers: 3,
|
||||||
|
max_cpu_milli: 4000,
|
||||||
|
max_memory_mb: 8192,
|
||||||
|
max_storage_gb: 50,
|
||||||
|
},
|
||||||
|
sessions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.state.accounts[username] = newAcc;
|
||||||
|
|
||||||
|
sendJSON(ctx.res, 201, {
|
||||||
|
id: `mock-${username}`,
|
||||||
|
username: username,
|
||||||
|
email: newAcc.email,
|
||||||
|
role: newAcc.role,
|
||||||
|
disabled: false,
|
||||||
|
email_verified: true,
|
||||||
|
server_count: 0,
|
||||||
|
must_change_password: newAcc.mustChangePassword,
|
||||||
|
created_at: newAcc.created_at,
|
||||||
|
updated_at: newAcc.updated_at,
|
||||||
|
} as UserView);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes starting with /api/v1/users/{id}
|
||||||
|
if (userIdOrAction) {
|
||||||
|
const rawId = userIdOrAction;
|
||||||
|
const accountKey = rawId.startsWith("mock-") ? rawId.substring(5) : rawId;
|
||||||
|
const acc = ctx.state.accounts[accountKey];
|
||||||
|
|
||||||
|
if (!acc) {
|
||||||
|
sendError(ctx.res, 404, "not_found", "user not found");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subAction = ctx.parts[4];
|
||||||
|
|
||||||
|
// GET /api/v1/users/{id}
|
||||||
|
if (is("GET", ctx) && !subAction) {
|
||||||
|
const serverCount = ctx.state.servers.filter((s) => s.owner === acc.id).length;
|
||||||
|
const linked_accounts = acc.linked ? [{
|
||||||
|
mc_uuid: MC_UUID,
|
||||||
|
auth_source: "mojang",
|
||||||
|
verified_at: new Date().toISOString()
|
||||||
|
}] : [];
|
||||||
|
|
||||||
|
const detail: UserDetail = {
|
||||||
|
id: `mock-${acc.id}`,
|
||||||
|
username: acc.id,
|
||||||
|
email: acc.email,
|
||||||
|
role: acc.role,
|
||||||
|
disabled: !!acc.disabled,
|
||||||
|
email_verified: acc.emailVerified,
|
||||||
|
server_count: serverCount,
|
||||||
|
must_change_password: acc.mustChangePassword,
|
||||||
|
created_at: acc.created_at || new Date().toISOString(),
|
||||||
|
updated_at: acc.updated_at || new Date().toISOString(),
|
||||||
|
linked_accounts,
|
||||||
|
};
|
||||||
|
sendJSON(ctx.res, 200, detail);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/v1/users/{id}
|
||||||
|
if (is("PATCH", ctx) && !subAction) {
|
||||||
|
const body = await readJSON<PatchUserRequest>(ctx.req);
|
||||||
|
|
||||||
|
if (body.role !== undefined) {
|
||||||
|
if (body.role !== "admin" && body.role !== "user") {
|
||||||
|
sendError(ctx.res, 400, "bad_request", `role must be 'admin' or 'user', got ${body.role}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ctx.account.id === acc.id && body.role !== ctx.account.role) {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "cannot change your own role");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
acc.role = body.role;
|
||||||
|
}
|
||||||
|
if (body.email !== undefined) acc.email = body.email;
|
||||||
|
acc.updated_at = new Date().toISOString();
|
||||||
|
|
||||||
|
const serverCount = ctx.state.servers.filter((s) => s.owner === acc.id).length;
|
||||||
|
sendJSON(ctx.res, 200, {
|
||||||
|
id: `mock-${acc.id}`,
|
||||||
|
username: acc.id,
|
||||||
|
email: acc.email,
|
||||||
|
role: acc.role,
|
||||||
|
disabled: !!acc.disabled,
|
||||||
|
email_verified: acc.emailVerified,
|
||||||
|
server_count: serverCount,
|
||||||
|
must_change_password: acc.mustChangePassword,
|
||||||
|
created_at: acc.created_at || new Date().toISOString(),
|
||||||
|
updated_at: acc.updated_at,
|
||||||
|
} as UserView);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/users/{id}
|
||||||
|
if (is("DELETE", ctx) && !subAction) {
|
||||||
|
if (ctx.account.id === acc.id) {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "cannot delete your own account");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const activeServers = ctx.state.servers.filter(
|
||||||
|
(s) => s.owner === acc.id && s.phase !== "Stopped" && s.phase !== "Failed"
|
||||||
|
);
|
||||||
|
if (activeServers.length > 0) {
|
||||||
|
sendError(
|
||||||
|
ctx.res,
|
||||||
|
409,
|
||||||
|
"active_servers",
|
||||||
|
"cannot delete user with active servers"
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.servers.forEach((s) => {
|
||||||
|
if (s.owner === acc.id) {
|
||||||
|
s.owner = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
delete ctx.state.accounts[accountKey];
|
||||||
|
sendJSON(ctx.res, 200, { deleted: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/users/{id}/disable
|
||||||
|
if (is("POST", ctx) && subAction === "disable") {
|
||||||
|
if (ctx.account.id === acc.id) {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "cannot disable your own account");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const body = await readJSON<{ disabled: boolean }>(ctx.req);
|
||||||
|
acc.disabled = !!body.disabled;
|
||||||
|
acc.updated_at = new Date().toISOString();
|
||||||
|
sendJSON(ctx.res, 200, { id: `mock-${acc.id}`, disabled: acc.disabled });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/users/{id}/reset-password
|
||||||
|
if (is("POST", ctx) && subAction === "reset-password") {
|
||||||
|
acc.mustChangePassword = true;
|
||||||
|
acc.updated_at = new Date().toISOString();
|
||||||
|
sendJSON(ctx.res, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/users/{id}/quotas
|
||||||
|
if (is("GET", ctx) && subAction === "quotas") {
|
||||||
|
if (!acc.quota) {
|
||||||
|
acc.quota = {
|
||||||
|
user_id: `mock-${acc.id}`,
|
||||||
|
max_servers: 3,
|
||||||
|
max_cpu_milli: 4000,
|
||||||
|
max_memory_mb: 8192,
|
||||||
|
max_storage_gb: 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
sendJSON(ctx.res, 200, acc.quota);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/v1/users/{id}/quotas
|
||||||
|
if (is("PUT", ctx) && subAction === "quotas") {
|
||||||
|
const body = await readJSON<QuotaInput>(ctx.req);
|
||||||
|
acc.quota = {
|
||||||
|
user_id: `mock-${acc.id}`,
|
||||||
|
max_servers: body.max_servers,
|
||||||
|
max_cpu_milli: body.max_cpu_milli,
|
||||||
|
max_memory_mb: body.max_memory_mb,
|
||||||
|
max_storage_gb: body.max_storage_gb,
|
||||||
|
};
|
||||||
|
acc.updated_at = new Date().toISOString();
|
||||||
|
sendJSON(ctx.res, 200, acc.quota);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/users/{id}/sessions
|
||||||
|
if (is("GET", ctx) && subAction === "sessions") {
|
||||||
|
if (!acc.sessions) {
|
||||||
|
acc.sessions = [
|
||||||
|
{
|
||||||
|
token_hash: "mock-token-hash-1",
|
||||||
|
created_at: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
expires_at: new Date(Date.now() + 3600000 * 24).toISOString(),
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
sendJSON(ctx.res, 200, { sessions: acc.sessions });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/users/{id}/sessions/{hash} — revoke single session
|
||||||
|
if (is("DELETE", ctx) && subAction === "sessions" && ctx.parts[5]) {
|
||||||
|
const hash = ctx.parts[5];
|
||||||
|
if (acc.sessions) {
|
||||||
|
acc.sessions = acc.sessions.filter((s) => s.token_hash !== hash);
|
||||||
|
}
|
||||||
|
sendJSON(ctx.res, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/users/{id}/sessions
|
||||||
|
if (is("DELETE", ctx) && subAction === "sessions" && !ctx.parts[5]) {
|
||||||
|
acc.sessions = [];
|
||||||
|
sendJSON(ctx.res, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/users/{id}/links — manual link
|
||||||
|
if (is("POST", ctx) && subAction === "links") {
|
||||||
|
const body = await readJSON<{ mc_uuid: string; auth_source?: string }>(ctx.req);
|
||||||
|
if (!body.mc_uuid) {
|
||||||
|
sendError(ctx.res, 400, "bad_request", "mc_uuid is required");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
acc.linked = true;
|
||||||
|
acc.updated_at = new Date().toISOString();
|
||||||
|
sendJSON(ctx.res, 200, { ok: true, mc_uuid: body.mc_uuid });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/users/{id}/links/{mc_uuid} — unlink
|
||||||
|
if (is("DELETE", ctx) && subAction === "links" && ctx.parts[5]) {
|
||||||
|
acc.linked = false;
|
||||||
|
acc.updated_at = new Date().toISOString();
|
||||||
|
sendJSON(ctx.res, 200, { ok: true, mc_uuid: ctx.parts[5] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||||
if (ctx.parts[2] !== "images") return false;
|
if (ctx.parts[2] !== "images") return false;
|
||||||
|
|
||||||
@@ -812,7 +1147,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// POST /api/v1/images (add image)
|
// POST /api/v1/images (add image)
|
||||||
if (is("POST", ctx) && ctx.parts.length === 3) {
|
if (is("POST", ctx) && ctx.parts.length === 3) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -836,7 +1171,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// DELETE /api/v1/images (remove image)
|
// DELETE /api/v1/images (remove image)
|
||||||
if (is("DELETE", ctx) && ctx.parts.length === 3) {
|
if (is("DELETE", ctx) && ctx.parts.length === 3) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -859,7 +1194,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// POST /api/v1/images/build (trigger build)
|
// POST /api/v1/images/build (trigger build)
|
||||||
if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
|
if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -899,7 +1234,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// GET /api/v1/images/build (list builds)
|
// GET /api/v1/images/build (list builds)
|
||||||
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
|
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -909,7 +1244,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// GET /api/v1/images/build/{id} (get build)
|
// GET /api/v1/images/build/{id} (get build)
|
||||||
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[4] && ctx.parts.length === 5) {
|
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[4] && ctx.parts.length === 5) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -924,7 +1259,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// POST /api/v1/images/build/{id}/cancel (cancel build)
|
// POST /api/v1/images/build/{id}/cancel (cancel build)
|
||||||
if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "cancel" && ctx.parts.length === 6) {
|
if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "cancel" && ctx.parts.length === 6) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -946,7 +1281,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// GET /api/v1/images/build/{id}/logs (SSE logs stream)
|
// GET /api/v1/images/build/{id}/logs (SSE logs stream)
|
||||||
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "logs" && ctx.parts.length === 6) {
|
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "logs" && ctx.parts.length === 6) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1358,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// GET /api/v1/submissions
|
// GET /api/v1/submissions
|
||||||
if (isAdminSubmissions && is("GET", ctx) && ctx.parts.length === 3) {
|
if (isAdminSubmissions && is("GET", ctx) && ctx.parts.length === 3) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1368,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// POST /api/v1/submissions/{id}/approve
|
// POST /api/v1/submissions/{id}/approve
|
||||||
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) {
|
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1084,7 +1419,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
|
|
||||||
// POST /api/v1/submissions/{id}/reject
|
// POST /api/v1/submissions/{id}/reject
|
||||||
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) {
|
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1156,7 +1491,7 @@ function streamBuildLogs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createServerRoute(ctx: SessionContext): Promise<void> {
|
async function createServerRoute(ctx: SessionContext): Promise<void> {
|
||||||
if (ctx.account.role !== "admin") {
|
if (!isAdmin(ctx.account.role)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1285,7 +1620,7 @@ async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Non-admins may restore only a world they formerly owned (spec §466).
|
// Non-admins may restore only a world they formerly owned (spec §466).
|
||||||
if (ctx.account.role !== "admin" && backup.former_owner !== ctx.account.id) {
|
if (!isAdmin(ctx.account.role) && backup.former_owner !== ctx.account.id) {
|
||||||
sendError(ctx.res, 403, "forbidden", "not the former owner of this world");
|
sendError(ctx.res, 403, "forbidden", "not the former owner of this world");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TierProvider } from "@/lib/tier";
|
|||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { RequireAdmin } from "@/components/RequireAdmin";
|
import { RequireAdmin } from "@/components/RequireAdmin";
|
||||||
import { RequireAuth } from "@/components/RequireAuth";
|
import { RequireAuth } from "@/components/RequireAuth";
|
||||||
|
import { RequireOwner } from "@/components/RequireOwner";
|
||||||
import { Login } from "@/pages/Login";
|
import { Login } from "@/pages/Login";
|
||||||
import { ChangePassword } from "@/pages/ChangePassword";
|
import { ChangePassword } from "@/pages/ChangePassword";
|
||||||
import { Dashboard } from "@/pages/Dashboard";
|
import { Dashboard } from "@/pages/Dashboard";
|
||||||
@@ -16,6 +17,8 @@ import { Account } from "@/pages/Account";
|
|||||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||||
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||||
|
import { UsersPage } from "@/pages/admin/UsersPage";
|
||||||
|
import { UserDetailPage } from "@/pages/admin/UserDetailPage";
|
||||||
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
||||||
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
|
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
|
||||||
|
|
||||||
@@ -61,6 +64,11 @@ export default function App() {
|
|||||||
<Route path="builds" element={<ImageBuildPage />} />
|
<Route path="builds" element={<ImageBuildPage />} />
|
||||||
<Route path="submissions" element={<SubmissionsPage />} />
|
<Route path="submissions" element={<SubmissionsPage />} />
|
||||||
<Route path="updates" element={<UpdatesPage />} />
|
<Route path="updates" element={<UpdatesPage />} />
|
||||||
|
{/* Owner-gated: user management (one level above admin). */}
|
||||||
|
<Route element={<RequireOwner />}>
|
||||||
|
<Route path="users" element={<UsersPage />} />
|
||||||
|
<Route path="users/:id" element={<UserDetailPage />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -137,12 +137,13 @@ function ThemeToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { isAdmin } = useTier();
|
const { isAdmin, isOwner } = useTier();
|
||||||
const { t, i18n } = useTranslation("navigation");
|
const { t, i18n } = useTranslation("navigation");
|
||||||
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
|
// Sections are derived purely from is_admin and is_owner: User-Side always,
|
||||||
// only for admins. isAdmin is fail-closed (false while /me loads or on failure),
|
// Admin-Side only for admins, Owner-Side only for the platform owner. Both
|
||||||
// so admin sections appear only once identity is confirmed.
|
// flags are fail-closed (false while /me loads or on failure), so admin and
|
||||||
const sections = visibleSections(isAdmin);
|
// owner sections appear only once identity is confirmed.
|
||||||
|
const sections = visibleSections(isAdmin, isOwner);
|
||||||
|
|
||||||
// Sync document metadata with the active language.
|
// Sync document metadata with the active language.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Loader2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateUserDialog({ onCreated }: Props) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [role, setRole] = useState<"user" | "admin">("user");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [mustChange, setMustChange] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setUsername("");
|
||||||
|
setEmail("");
|
||||||
|
setRole("user");
|
||||||
|
setPassword("");
|
||||||
|
setMustChange(true);
|
||||||
|
setErr(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
setErr(null);
|
||||||
|
|
||||||
|
if (!username.trim()) {
|
||||||
|
setErr(t("users_create_validation_username"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setErr(t("users_create_validation_password"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const u = await api.createUser({
|
||||||
|
username: username.trim(),
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
must_change_password: mustChange,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
onCreated(u.id);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" className="gap-1.5">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("users_create_btn")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("users_create_title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("users_create_desc")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Username */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{t("users_field_username")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t("users_create_username_placeholder")}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{t("users_field_email")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{t("users_field_role")}
|
||||||
|
</Label>
|
||||||
|
<Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
||||||
|
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{t("users_field_password")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="min. 8 characters"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Must change password toggle */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mustChange}
|
||||||
|
onChange={(e) => setMustChange(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{t("users_create_must_change")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<p className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={submitting} className="gap-1.5">
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("users_create_btn")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTier } from "@/lib/tier";
|
||||||
|
import { NotAuthorized } from "@/components/States";
|
||||||
|
import { Loading } from "@/components/States";
|
||||||
|
|
||||||
|
export function RequireOwner() {
|
||||||
|
const { loading, isOwner } = useTier();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
if (!isOwner) {
|
||||||
|
return <NotAuthorized />;
|
||||||
|
}
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -100,5 +100,94 @@
|
|||||||
"build_import_submission_none": "No matching submissions found or not loaded",
|
"build_import_submission_none": "No matching submissions found or not loaded",
|
||||||
"build_import_submission_hint": "Selecting a submission automatically populates the Image Reference, Context Reference, and the corresponding Dockerfile audit header.",
|
"build_import_submission_hint": "Selecting a submission automatically populates the Image Reference, Context Reference, and the corresponding Dockerfile audit header.",
|
||||||
"build_import_submission_warning_title": "Warning: This submission is currently \"{{status}}\"",
|
"build_import_submission_warning_title": "Warning: This submission is currently \"{{status}}\"",
|
||||||
"build_import_submission_warning_desc": "Manually triggering a build will not automatically mark this submission as approved, nor will it update its associated build status in the database. Use for emergency debugging or testing only."
|
"build_import_submission_warning_desc": "Manually triggering a build will not automatically mark this submission as approved, nor will it update its associated build status in the database. Use for emergency debugging or testing only.",
|
||||||
|
|
||||||
|
"_users_comment": "User administration (admin-tier only).",
|
||||||
|
"users_title": "Users",
|
||||||
|
"users_subtitle": "Manage platform user accounts, quotas, and sessions.",
|
||||||
|
"users_create_btn": "Create User",
|
||||||
|
"users_create_title": "Create New User",
|
||||||
|
"users_create_desc": "Create a new platform account. The user will receive the initial password and will be prompted to change it on first login if the toggle is enabled.",
|
||||||
|
"users_create_username_placeholder": "e.g. alice",
|
||||||
|
"users_create_validation_username": "Username is required.",
|
||||||
|
"users_create_validation_password": "Password must be at least 8 characters.",
|
||||||
|
"users_create_must_change": "Require password change on first login",
|
||||||
|
"users_search_placeholder": "Search username or email...",
|
||||||
|
"users_search_btn": "Search",
|
||||||
|
"users_filter_role_all": "All Roles",
|
||||||
|
"users_filter_status_all": "All Status",
|
||||||
|
"users_status_active": "Active",
|
||||||
|
"users_status_disabled": "Disabled",
|
||||||
|
"users_role_admin": "Admin",
|
||||||
|
"users_role_owner": "Owner",
|
||||||
|
"users_role_user": "User",
|
||||||
|
"users_col_role": "Role",
|
||||||
|
"users_col_servers": "Servers",
|
||||||
|
"users_col_status": "Status",
|
||||||
|
"users_col_created": "Created",
|
||||||
|
"users_view_detail": "Details",
|
||||||
|
"users_total_count": "{{count}} total users",
|
||||||
|
"users_empty_title": "No Users Found",
|
||||||
|
"users_empty_hint": "No users match the current filters.",
|
||||||
|
"users_back_to_list": "Back to user list",
|
||||||
|
"users_edit_profile": "Edit Profile",
|
||||||
|
"users_field_username": "Username",
|
||||||
|
"users_field_email": "Email",
|
||||||
|
"users_field_role": "Role",
|
||||||
|
"users_field_password": "Initial Password",
|
||||||
|
"users_save_btn": "Save Changes",
|
||||||
|
"users_save_ok": "Changes saved successfully.",
|
||||||
|
"users_linked_accounts": "Linked Minecraft Accounts",
|
||||||
|
"users_no_linked": "No Minecraft accounts linked yet.",
|
||||||
|
"users_link_add": "Link Account",
|
||||||
|
"users_link_source": "Auth Source",
|
||||||
|
"users_link_confirm": "Link",
|
||||||
|
"users_unlink_tooltip": "Unlink this Minecraft account",
|
||||||
|
"users_unlink_dlg_title": "Unlink Minecraft Account",
|
||||||
|
"users_unlink_dlg_desc": "Are you sure you want to unlink this Minecraft account? The user will lose any in-game identity tied to this UUID.",
|
||||||
|
"users_unlink_btn": "Unlink",
|
||||||
|
"users_quotas": "Quotas",
|
||||||
|
"users_quota_servers": "Max Servers",
|
||||||
|
"users_quota_cpu": "Max CPU (millicore)",
|
||||||
|
"users_quota_memory": "Max Memory (MiB)",
|
||||||
|
"users_quota_storage": "Max Storage (GiB)",
|
||||||
|
"users_quota_unlimited": "Unlimited",
|
||||||
|
"users_sessions": "Sessions",
|
||||||
|
"users_no_sessions": "No active sessions.",
|
||||||
|
"users_session_expires": "Expires",
|
||||||
|
"users_sessions_revoke_all": "Revoke All",
|
||||||
|
"users_session_revoke_one": "Revoke this session",
|
||||||
|
"users_sessions_revoked": "All sessions revoked.",
|
||||||
|
"users_session_revoke_one_dlg_title": "Revoke Session",
|
||||||
|
"users_session_revoke_one_dlg_desc": "Are you sure you want to revoke this session? The user will be logged out from this device immediately.",
|
||||||
|
"users_session_revoke_all_dlg_title": "Revoke All Sessions",
|
||||||
|
"users_session_revoke_all_dlg_desc": "Are you sure you want to revoke all active sessions? The user will be logged out from every device.",
|
||||||
|
"users_session_revoke_confirm": "Revoke",
|
||||||
|
"users_danger_zone": "Danger Zone",
|
||||||
|
"users_danger_disable": "Disable User",
|
||||||
|
"users_danger_disable_desc": "Prevent this user from logging in. All active sessions will be revoked immediately.",
|
||||||
|
"users_danger_disable_btn": "Disable User",
|
||||||
|
"users_danger_enable": "Enable User",
|
||||||
|
"users_danger_enable_desc": "Allow this user to log in again.",
|
||||||
|
"users_danger_enable_btn": "Enable",
|
||||||
|
"users_danger_reset_pw": "Reset Password",
|
||||||
|
"users_danger_reset_pw_desc": "A high-entropy random password will be generated and the user will be forced to change it on next login. All active sessions are revoked immediately.",
|
||||||
|
"users_danger_reset_pw_desc_email": "A high-entropy random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All active sessions are revoked immediately.",
|
||||||
|
"users_danger_reset_pw_btn": "Reset Password",
|
||||||
|
"users_danger_reset_pw_confirm": "Reset Password",
|
||||||
|
"users_pw_reset_ok": "Password reset. The new password was sent to {{email}}.",
|
||||||
|
"users_danger_disable_dlg_title": "Disable User",
|
||||||
|
"users_danger_disable_dlg_desc": "This user will be unable to log in. All active sessions will be revoked immediately.",
|
||||||
|
"users_danger_enable_dlg_title": "Enable User",
|
||||||
|
"users_danger_enable_dlg_desc": "This user will be able to log in again.",
|
||||||
|
"users_danger_reset_pw_dlg_title": "Reset Password",
|
||||||
|
"users_danger_reset_pw_dlg_desc_email": "A high-entropy random password will be generated and sent to {{email}}. The user will be forced to change it on next login. All existing sessions will be revoked.",
|
||||||
|
"users_danger_reset_pw_dlg_desc_no_email": "A high-entropy random password will be generated. Since this user has no email address, it will be logged server-side. The user will be forced to change it on next login. All existing sessions will be revoked.",
|
||||||
|
"users_danger_delete_dlg_title": "Delete User",
|
||||||
|
"users_danger_delete_dlg_desc": "This action is permanent. The user's owned servers will be released, all sessions revoked, and the account permanently disabled. This cannot be undone through the panel.",
|
||||||
|
"users_danger_delete": "Delete User",
|
||||||
|
"users_danger_delete_desc": "Soft-delete this user. Owned servers are released, all sessions are revoked, and the account is permanently disabled. This action cannot be undone through the panel.",
|
||||||
|
"users_danger_delete_btn": "Delete User",
|
||||||
|
"users_danger_delete_confirm": "This action is permanent. The user's servers will be released and their sessions revoked. Are you absolutely sure?",
|
||||||
|
"users_danger_delete_yes": "Yes, Delete Permanently"
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
"my_submissions": "My Submissions",
|
"my_submissions": "My Submissions",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"admin_section": "Admin",
|
"admin_section": "Admin",
|
||||||
|
"owner_section": "Platform",
|
||||||
|
"admin_users": "Users",
|
||||||
"admin_images": "Images",
|
"admin_images": "Images",
|
||||||
"admin_builds": "Build Pipeline",
|
"admin_builds": "Build Pipeline",
|
||||||
"admin_submissions": "Submissions",
|
"admin_submissions": "Submissions",
|
||||||
|
|||||||
@@ -100,5 +100,94 @@
|
|||||||
"build_import_submission_none": "无匹配的提交或暂未加载",
|
"build_import_submission_none": "无匹配的提交或暂未加载",
|
||||||
"build_import_submission_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
|
"build_import_submission_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
|
||||||
"build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
|
"build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
|
||||||
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。"
|
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
|
||||||
|
|
||||||
|
"_users_comment": "用户管理(仅管理员可见)。",
|
||||||
|
"users_title": "用户管理",
|
||||||
|
"users_subtitle": "管理平台用户账号、配额和会话。",
|
||||||
|
"users_create_btn": "创建用户",
|
||||||
|
"users_create_title": "创建新用户",
|
||||||
|
"users_create_desc": "创建一个新的平台账号。用户将收到初始密码,如果开启「首次登录修改密码」,用户将在首次登录时被要求修改密码。",
|
||||||
|
"users_create_username_placeholder": "例如: alice",
|
||||||
|
"users_create_validation_username": "用户名为必填项。",
|
||||||
|
"users_create_validation_password": "密码至少需要 8 个字符。",
|
||||||
|
"users_create_must_change": "要求首次登录修改密码",
|
||||||
|
"users_search_placeholder": "搜索用户名或邮箱...",
|
||||||
|
"users_search_btn": "搜索",
|
||||||
|
"users_filter_role_all": "全部角色",
|
||||||
|
"users_filter_status_all": "全部状态",
|
||||||
|
"users_status_active": "正常",
|
||||||
|
"users_status_disabled": "已禁用",
|
||||||
|
"users_role_admin": "管理员",
|
||||||
|
"users_role_owner": "所有者",
|
||||||
|
"users_role_user": "普通用户",
|
||||||
|
"users_col_role": "角色",
|
||||||
|
"users_col_servers": "服务器数",
|
||||||
|
"users_col_status": "状态",
|
||||||
|
"users_col_created": "创建时间",
|
||||||
|
"users_view_detail": "详情",
|
||||||
|
"users_total_count": "共 {{count}} 个用户",
|
||||||
|
"users_empty_title": "未找到用户",
|
||||||
|
"users_empty_hint": "没有匹配当前筛选条件的用户。",
|
||||||
|
"users_back_to_list": "返回用户列表",
|
||||||
|
"users_edit_profile": "编辑资料",
|
||||||
|
"users_field_username": "用户名",
|
||||||
|
"users_field_email": "邮箱",
|
||||||
|
"users_field_role": "角色",
|
||||||
|
"users_field_password": "初始密码",
|
||||||
|
"users_save_btn": "保存更改",
|
||||||
|
"users_save_ok": "更改保存成功。",
|
||||||
|
"users_linked_accounts": "已关联的 Minecraft 账号",
|
||||||
|
"users_no_linked": "尚未关联任何 Minecraft 账号。",
|
||||||
|
"users_link_add": "关联账号",
|
||||||
|
"users_link_source": "验证源",
|
||||||
|
"users_link_confirm": "关联",
|
||||||
|
"users_unlink_tooltip": "解除此 Minecraft 账号关联",
|
||||||
|
"users_unlink_dlg_title": "解除 Minecraft 关联",
|
||||||
|
"users_unlink_dlg_desc": "确定解除此 Minecraft 账号的关联吗?用户将失去该 UUID 绑定的游戏内身份。",
|
||||||
|
"users_unlink_btn": "解除关联",
|
||||||
|
"users_quotas": "配额",
|
||||||
|
"users_quota_servers": "最大服务器数",
|
||||||
|
"users_quota_cpu": "最大 CPU(毫核)",
|
||||||
|
"users_quota_memory": "最大内存(MiB)",
|
||||||
|
"users_quota_storage": "最大存储(GiB)",
|
||||||
|
"users_quota_unlimited": "无限制",
|
||||||
|
"users_sessions": "活跃会话",
|
||||||
|
"users_no_sessions": "无活跃会话。",
|
||||||
|
"users_session_expires": "过期时间",
|
||||||
|
"users_sessions_revoke_all": "全部撤销",
|
||||||
|
"users_session_revoke_one": "单独撤销",
|
||||||
|
"users_sessions_revoked": "所有会话已撤销。",
|
||||||
|
"users_session_revoke_one_dlg_title": "撤销会话",
|
||||||
|
"users_session_revoke_one_dlg_desc": "确定撤销此会话吗?用户将立即从该设备登出。",
|
||||||
|
"users_session_revoke_all_dlg_title": "撤销所有会话",
|
||||||
|
"users_session_revoke_all_dlg_desc": "确定撤销所有活跃会话吗?用户将从所有设备登出。",
|
||||||
|
"users_session_revoke_confirm": "撤销",
|
||||||
|
"users_danger_zone": "危险操作区",
|
||||||
|
"users_danger_disable": "禁用用户",
|
||||||
|
"users_danger_disable_desc": "阻止此用户登录。所有活跃会话将被立即撤销。",
|
||||||
|
"users_danger_disable_btn": "禁用用户",
|
||||||
|
"users_danger_enable": "启用用户",
|
||||||
|
"users_danger_enable_desc": "允许此用户重新登录。",
|
||||||
|
"users_danger_enable_btn": "启用",
|
||||||
|
"users_danger_reset_pw": "重置密码",
|
||||||
|
"users_danger_reset_pw_desc": "将生成高熵随机密码,用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
|
||||||
|
"users_danger_reset_pw_desc_email": "将生成高熵随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有活跃会话将被立即撤销。",
|
||||||
|
"users_danger_reset_pw_btn": "重置密码",
|
||||||
|
"users_danger_reset_pw_confirm": "确认重置",
|
||||||
|
"users_pw_reset_ok": "密码已重置,新密码已发送至 {{email}}。",
|
||||||
|
"users_danger_disable_dlg_title": "禁用用户",
|
||||||
|
"users_danger_disable_dlg_desc": "此用户将无法登录。所有活跃会话将被立即撤销。",
|
||||||
|
"users_danger_enable_dlg_title": "启用用户",
|
||||||
|
"users_danger_enable_dlg_desc": "此用户将可以重新登录。",
|
||||||
|
"users_danger_reset_pw_dlg_title": "重置密码",
|
||||||
|
"users_danger_reset_pw_dlg_desc_email": "将生成高熵随机密码并发送至 {{email}}。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
|
||||||
|
"users_danger_reset_pw_dlg_desc_no_email": "将生成高熵随机密码。由于此用户未设置邮箱地址,密码将记录在服务端日志中。用户下次登录时将被强制修改密码。所有现有会话将被撤销。",
|
||||||
|
"users_danger_delete_dlg_title": "删除用户",
|
||||||
|
"users_danger_delete_dlg_desc": "此操作不可逆。该用户拥有的所有服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
||||||
|
"users_danger_delete": "删除用户",
|
||||||
|
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
||||||
|
"users_danger_delete_btn": "删除用户",
|
||||||
|
"users_danger_delete_confirm": "此操作不可逆。该用户的服务器将被释放,会话将被撤销。确定要执行吗?",
|
||||||
|
"users_danger_delete_yes": "是的,永久删除"
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
"my_submissions": "我的提交",
|
"my_submissions": "我的提交",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"admin_section": "管理",
|
"admin_section": "管理",
|
||||||
|
"owner_section": "平台",
|
||||||
|
"admin_users": "用户管理",
|
||||||
"admin_images": "镜像",
|
"admin_images": "镜像",
|
||||||
"admin_builds": "构建流水线",
|
"admin_builds": "构建流水线",
|
||||||
"admin_submissions": "审核提交",
|
"admin_submissions": "审核提交",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BanlistResult,
|
BanlistResult,
|
||||||
Build,
|
Build,
|
||||||
CreateServerRequest,
|
CreateServerRequest,
|
||||||
|
CreateUserRequest,
|
||||||
FleetServer,
|
FleetServer,
|
||||||
Identity,
|
Identity,
|
||||||
KickResult,
|
KickResult,
|
||||||
@@ -13,8 +14,14 @@ import type {
|
|||||||
LinkStatus,
|
LinkStatus,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
BindResult,
|
BindResult,
|
||||||
|
PatchUserRequest,
|
||||||
PlayersResult,
|
PlayersResult,
|
||||||
|
QuotaInput,
|
||||||
|
QuotaView,
|
||||||
ServerInfo,
|
ServerInfo,
|
||||||
|
SessionView,
|
||||||
|
UserDetail,
|
||||||
|
UserView,
|
||||||
WhitelistImage,
|
WhitelistImage,
|
||||||
WhitelistResult,
|
WhitelistResult,
|
||||||
Submission,
|
Submission,
|
||||||
@@ -339,6 +346,72 @@ export const api = {
|
|||||||
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
||||||
|
|
||||||
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
|
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
|
||||||
|
|
||||||
|
// ---- User admin (admin-tier, spec §7 user admin) ----
|
||||||
|
|
||||||
|
listUsers: (params?: {
|
||||||
|
query?: string;
|
||||||
|
role?: "admin" | "user";
|
||||||
|
disabled?: "true" | "false";
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params?.query) sp.set("query", params.query);
|
||||||
|
if (params?.role) sp.set("role", params.role);
|
||||||
|
if (params?.disabled) sp.set("disabled", params.disabled);
|
||||||
|
if (params?.limit) sp.set("limit", String(params.limit));
|
||||||
|
if (params?.offset) sp.set("offset", String(params.offset));
|
||||||
|
const qs = sp.toString();
|
||||||
|
return request<{ users: UserView[]; total: number }>(
|
||||||
|
"GET",
|
||||||
|
`/users${qs ? `?${qs}` : ""}`,
|
||||||
|
).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 }));
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: (id: string) => request<UserDetail>("GET", `/users/${id}`),
|
||||||
|
|
||||||
|
createUser: (req: CreateUserRequest) =>
|
||||||
|
request<UserView>("POST", "/users", req),
|
||||||
|
|
||||||
|
patchUser: (id: string, patch: PatchUserRequest) =>
|
||||||
|
request<UserView>("PATCH", `/users/${id}`, patch),
|
||||||
|
|
||||||
|
deleteUser: (id: string) =>
|
||||||
|
request<{ deleted: boolean }>("DELETE", `/users/${id}`),
|
||||||
|
|
||||||
|
disableUser: (id: string, disabled: boolean) =>
|
||||||
|
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
|
||||||
|
|
||||||
|
resetUserPassword: (id: string) =>
|
||||||
|
request<{ ok: boolean; email: string }>("POST", `/users/${id}/reset-password`),
|
||||||
|
|
||||||
|
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
|
||||||
|
|
||||||
|
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
||||||
|
request<QuotaView>("PUT", `/users/${id}/quotas`, quotas),
|
||||||
|
|
||||||
|
listUserSessions: (id: string) =>
|
||||||
|
request<{ sessions: SessionView[] }>("GET", `/users/${id}/sessions`).then((r) => r.sessions ?? []),
|
||||||
|
|
||||||
|
revokeUserSessions: (id: string) =>
|
||||||
|
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions`),
|
||||||
|
|
||||||
|
revokeUserSession: (id: string, hash: string) =>
|
||||||
|
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`),
|
||||||
|
|
||||||
|
linkAccount: (id: string, mcUuid: string, authSource?: string) =>
|
||||||
|
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
|
||||||
|
"POST",
|
||||||
|
`/users/${id}/links`,
|
||||||
|
{ mc_uuid: mcUuid, auth_source: authSource ?? "mojang" },
|
||||||
|
),
|
||||||
|
|
||||||
|
unlinkAccount: (id: string, mcUuid: string) =>
|
||||||
|
request<{ ok: boolean; mc_uuid: string }>(
|
||||||
|
"DELETE",
|
||||||
|
`/users/${id}/links/${encodeURIComponent(mcUuid)}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+24
-4
@@ -2,6 +2,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Server,
|
Server,
|
||||||
UserRound,
|
UserRound,
|
||||||
|
Users,
|
||||||
Boxes,
|
Boxes,
|
||||||
Cpu,
|
Cpu,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -30,11 +31,14 @@ export interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NavSection {
|
export interface NavSection {
|
||||||
id: "user" | "admin";
|
id: "user" | "admin" | "owner";
|
||||||
/** Section heading key; null renders no heading (User-Side flat list). */
|
/** Section heading key; null renders no heading (User-Side flat list). */
|
||||||
titleKey: string | null;
|
titleKey: string | null;
|
||||||
/** When true the section is shown only to admins (is_admin === true). */
|
/** When true the section is shown only to admins (is_admin === true). */
|
||||||
adminOnly: boolean;
|
adminOnly: boolean;
|
||||||
|
/** When true the section is shown only to the owner (is_owner === true).
|
||||||
|
* An owner-visible section is implicitly invisible to a plain admin. */
|
||||||
|
ownerOnly: boolean;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
id: "user",
|
id: "user",
|
||||||
titleKey: null,
|
titleKey: null,
|
||||||
adminOnly: false,
|
adminOnly: false,
|
||||||
|
ownerOnly: false,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
|
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
|
||||||
{ to: "/servers", key: "my_servers", icon: Server },
|
{ to: "/servers", key: "my_servers", icon: Server },
|
||||||
@@ -54,6 +59,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
id: "admin",
|
id: "admin",
|
||||||
titleKey: "admin_section",
|
titleKey: "admin_section",
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
|
ownerOnly: false,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||||
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||||
@@ -61,14 +67,28 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
|
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "owner",
|
||||||
|
titleKey: "owner_section",
|
||||||
|
adminOnly: false,
|
||||||
|
ownerOnly: true,
|
||||||
|
items: [
|
||||||
|
{ to: "/admin/users", key: "admin_users", icon: Users },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* visibleSections returns the sections a caller with the given admin flag may see.
|
* visibleSections returns the sections a caller with the given admin flag may see.
|
||||||
* Pure and total: a non-admin (or the fail-closed `false` used while /me is still
|
* Pure and total: a non-admin (or the fail-closed `false` used while /me is still
|
||||||
* loading or after it errors) gets exactly the User-Side section; an admin gets all
|
* loading or after it errors) gets exactly the User-Side section; an admin gets all
|
||||||
* three. This is the single decision the sidebar renders from.
|
* admin sections; an owner additionally gets the owner-gated sections. This is the
|
||||||
|
* single decision the sidebar renders from.
|
||||||
*/
|
*/
|
||||||
export function visibleSections(isAdmin: boolean): NavSection[] {
|
export function visibleSections(isAdmin: boolean, isOwner: boolean): NavSection[] {
|
||||||
return NAV_SECTIONS.filter((s) => !s.adminOnly || isAdmin);
|
return NAV_SECTIONS.filter((s) => {
|
||||||
|
if (s.ownerOnly) return isOwner;
|
||||||
|
if (s.adminOnly) return isAdmin;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,9 @@ import { deriveAuth, type AuthState } from "./auth";
|
|||||||
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
||||||
|
|
||||||
export interface TierState extends AuthState {
|
export interface TierState extends AuthState {
|
||||||
|
/** Owner (platform-level, one above admin) — exposed so nav can show the Users
|
||||||
|
* section only to the owner identity. Computed server-side, fail-closed. */
|
||||||
|
isOwner: boolean;
|
||||||
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
|
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
|
||||||
* navigation after the context has settled (login → refresh → redirect). */
|
* navigation after the context has settled (login → refresh → redirect). */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -42,6 +45,7 @@ const TierContext = createContext<TierState>({
|
|||||||
identity: null,
|
identity: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
isOwner: false,
|
||||||
unauthenticated: false,
|
unauthenticated: false,
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
@@ -82,9 +86,13 @@ export function TierProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const state = deriveAuth(identity, error, loading);
|
const state = deriveAuth(identity, error, loading);
|
||||||
|
// isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the
|
||||||
|
// backend grades by operation), but isOwner gates user management. Both are
|
||||||
|
// fail-closed — identity === null or missing fields → false.
|
||||||
|
const isOwner = identity?.is_owner === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TierContext.Provider value={{ ...state, refresh }}>
|
<TierContext.Provider value={{ ...state, isOwner, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</TierContext.Provider>
|
</TierContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
+67
-2
@@ -208,8 +208,11 @@ export interface ApiError {
|
|||||||
export interface Identity {
|
export interface Identity {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: "user" | "admin";
|
role: "user" | "admin" | "owner";
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
/** Server-computed Principal.IsOwner() — true only for the platform-level
|
||||||
|
* owner account (one above admin). Owners get user management; admins don't. */
|
||||||
|
is_owner: boolean;
|
||||||
/** Local-password path only: the account owes a forced first-login password
|
/** Local-password path only: the account owes a forced first-login password
|
||||||
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
|
* change. The JWT/Access path always leaves it false. Like `is_admin` it crosses
|
||||||
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
|
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
|
||||||
@@ -224,7 +227,7 @@ export interface Identity {
|
|||||||
* change-password card before any other surface. */
|
* change-password card before any other surface. */
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
role: "user" | "admin";
|
role: "user" | "admin" | "owner";
|
||||||
must_change_password: boolean;
|
must_change_password: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,3 +277,65 @@ export interface UpdateWindow {
|
|||||||
start: string | null;
|
start: string | null;
|
||||||
end: string | null;
|
end: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- User admin types (internal/api/repo.go UserView, UserDetail, QuotaView, SessionView) ----
|
||||||
|
|
||||||
|
export interface UserView {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: "admin" | "user" | "owner";
|
||||||
|
disabled: boolean;
|
||||||
|
email_verified: boolean;
|
||||||
|
server_count: number;
|
||||||
|
must_change_password: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkedAccount {
|
||||||
|
mc_uuid: string;
|
||||||
|
auth_source: string;
|
||||||
|
verified_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetail extends UserView {
|
||||||
|
deleted_at?: string | null;
|
||||||
|
linked_accounts: LinkedAccount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
role: "admin" | "user";
|
||||||
|
password: string;
|
||||||
|
must_change_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchUserRequest {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
role?: "admin" | "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaView {
|
||||||
|
user_id: string;
|
||||||
|
max_servers?: number | null;
|
||||||
|
max_cpu_milli?: number | null;
|
||||||
|
max_memory_mb?: number | null;
|
||||||
|
max_storage_gb?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaInput {
|
||||||
|
max_servers?: number | null;
|
||||||
|
max_cpu_milli?: number | null;
|
||||||
|
max_memory_mb?: number | null;
|
||||||
|
max_storage_gb?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionView {
|
||||||
|
token_hash: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,949 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
UserRound,
|
||||||
|
Mail,
|
||||||
|
Shield,
|
||||||
|
Crown,
|
||||||
|
Calendar,
|
||||||
|
Circle,
|
||||||
|
Key,
|
||||||
|
Clock,
|
||||||
|
Trash2,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
RefreshCw,
|
||||||
|
Unlink,
|
||||||
|
Link,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Loading, ErrorState } from "@/components/States";
|
||||||
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
import { useAsync } from "@/lib/hooks";
|
||||||
|
import { useTier } from "@/lib/tier";
|
||||||
|
import { formatAbsolute } from "@/lib/format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UserDetail, SessionView } from "@/lib/types";
|
||||||
|
|
||||||
|
export function UserDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t, i18n } = useTranslation("admin");
|
||||||
|
const locale = i18n.language;
|
||||||
|
const { identity } = useTier();
|
||||||
|
const isSelf = identity?.user_id === id;
|
||||||
|
|
||||||
|
const { data: user, error, loading, reload } = useAsync(
|
||||||
|
() => api.getUser(id!),
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading && !user) return <Loading />;
|
||||||
|
if (error) return <ErrorState error={error} onRetry={reload} />;
|
||||||
|
if (!user) return <ErrorState error={new Error("user not found")} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/admin/users")}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{t("users_back_to_list")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"rounded-full p-2",
|
||||||
|
user.role === "admin" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
<UserRound className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{user.username}</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||||
|
{user.email && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
{user.email}
|
||||||
|
{user.email_verified && (
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{formatAbsolute(user.created_at, locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user.disabled ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-3 py-1 text-xs font-medium text-destructive">
|
||||||
|
<Circle className="h-2 w-2 fill-destructive" />
|
||||||
|
{t("users_status_disabled")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-500">
|
||||||
|
<Circle className="h-2 w-2 fill-emerald-500" />
|
||||||
|
{t("users_status_active")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium",
|
||||||
|
user.role === "owner"
|
||||||
|
? "bg-yellow-500/10 text-yellow-600"
|
||||||
|
: user.role === "admin"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{user.role === "owner" ? (
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Edit profile */}
|
||||||
|
<EditProfileCard user={user} onSaved={reload} isSelf={identity?.user_id === id} />
|
||||||
|
{/* Linked accounts */}
|
||||||
|
<LinkedAccountsCard user={user} onChanged={reload} />
|
||||||
|
{/* Quotas */}
|
||||||
|
<QuotasCard userId={user.id} />
|
||||||
|
{/* Sessions */}
|
||||||
|
<SessionsCard userId={user.id} onChanged={reload} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<DangerZone user={user} onChanged={reload} navigate={navigate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved: () => void; isSelf: boolean }) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const [username, setUsername] = useState(user.username);
|
||||||
|
const [email, setEmail] = useState(user.email ?? "");
|
||||||
|
const [role, setRole] = useState(user.role);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [ok, setOk] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const dirty = username !== user.username || email !== (user.email ?? "") || role !== user.role;
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!dirty) return;
|
||||||
|
setSaving(true);
|
||||||
|
setErr(null);
|
||||||
|
setOk(null);
|
||||||
|
try {
|
||||||
|
const patch: any = {};
|
||||||
|
if (username !== user.username) patch.username = username;
|
||||||
|
if (email !== (user.email ?? "")) patch.email = email;
|
||||||
|
if (role !== user.role) patch.role = role;
|
||||||
|
await api.patchUser(user.id, patch);
|
||||||
|
setOk(t("users_save_ok"));
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold">{t("users_edit_profile")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_username")}</Label>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_email")}</Label>
|
||||||
|
<Input
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isSelf && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_field_role")}</Label>
|
||||||
|
<Select value={role} onValueChange={(v: "admin" | "user") => setRole(v)}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
||||||
|
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{err}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ok && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{ok}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("users_save_btn")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged: () => void }) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const accounts = user.linked_accounts ?? [];
|
||||||
|
const [unlinking, setUnlinking] = useState<string | null>(null);
|
||||||
|
const [unlinkDlg, setUnlinkDlg] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [newUUID, setNewUUID] = useState("");
|
||||||
|
const [newSource, setNewSource] = useState("mojang");
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleUnlinkConfirm() {
|
||||||
|
if (!unlinkDlg) return;
|
||||||
|
const mcUuid = unlinkDlg;
|
||||||
|
setUnlinking(mcUuid);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await api.unlinkAccount(user.id, mcUuid);
|
||||||
|
setUnlinkDlg(null);
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
} finally {
|
||||||
|
setUnlinking(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newUUID.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await api.linkAccount(user.id, newUUID.trim(), newSource);
|
||||||
|
setNewUUID("");
|
||||||
|
setShowAdd(false);
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
{t("users_linked_accounts")} ({accounts.length})
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs"
|
||||||
|
onClick={() => setShowAdd(!showAdd)}
|
||||||
|
>
|
||||||
|
<Link className="h-3.5 w-3.5" />
|
||||||
|
{showAdd ? t("common:cancel") : t("users_link_add")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Add form */}
|
||||||
|
{showAdd && (
|
||||||
|
<form onSubmit={handleAdd} className="space-y-3 rounded-md border border-border/50 bg-muted/20 p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label className="text-[11px] font-semibold text-muted-foreground">Minecraft UUID</Label>
|
||||||
|
<Input
|
||||||
|
value={newUUID}
|
||||||
|
onChange={(e) => setNewUUID(e.target.value)}
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
className="h-8 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-28 space-y-1.5">
|
||||||
|
<Label className="text-[11px] font-semibold text-muted-foreground">{t("users_link_source")}</Label>
|
||||||
|
<Select value={newSource} onValueChange={setNewSource}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="mojang">Mojang</SelectItem>
|
||||||
|
<SelectItem value="thirdparty">Third-party Yggdrasil</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{err && (
|
||||||
|
<p className="text-xs text-destructive">{err}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" size="sm" disabled={adding || !newUUID.trim()} className="h-8 text-xs gap-1">
|
||||||
|
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link className="h-3.5 w-3.5" />}
|
||||||
|
{t("users_link_confirm")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("users_no_linked")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{accounts.map((acc) => (
|
||||||
|
<div key={acc.mc_uuid} className="flex items-center justify-between rounded-md border border-border/50 bg-muted/20 pl-3 pr-1 py-2 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-mono text-xs truncate block">{acc.mc_uuid}</span>
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{acc.auth_source} · {formatAbsolute(acc.verified_at, "en-US")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 mr-0.5"
|
||||||
|
disabled={unlinking === acc.mc_uuid}
|
||||||
|
onClick={() => setUnlinkDlg(acc.mc_uuid)}
|
||||||
|
title={t("users_unlink_tooltip")}
|
||||||
|
>
|
||||||
|
{unlinking === acc.mc_uuid ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Unlink className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Unlink confirmation dialog */}
|
||||||
|
<Dialog open={!!unlinkDlg} onOpenChange={() => setUnlinkDlg(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("users_unlink_dlg_title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("users_unlink_dlg_desc")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setUnlinkDlg(null)}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleUnlinkConfirm}>
|
||||||
|
{t("users_unlink_btn")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuotasCard({ userId }: { userId: string }) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const { data: quotas, error, loading, reload } = useAsync(
|
||||||
|
() => api.getUserQuotas(userId),
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [maxServers, setMaxServers] = useState<string>("");
|
||||||
|
const [maxCpu, setMaxCpu] = useState<string>("");
|
||||||
|
const [maxMem, setMaxMem] = useState<string>("");
|
||||||
|
const [maxStorage, setMaxStorage] = useState<string>("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [ok, setOk] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (quotas) {
|
||||||
|
setMaxServers(quotas.max_servers != null ? String(quotas.max_servers) : "");
|
||||||
|
setMaxCpu(quotas.max_cpu_milli != null ? String(quotas.max_cpu_milli) : "");
|
||||||
|
setMaxMem(quotas.max_memory_mb != null ? String(quotas.max_memory_mb) : "");
|
||||||
|
setMaxStorage(quotas.max_storage_gb != null ? String(quotas.max_storage_gb) : "");
|
||||||
|
}
|
||||||
|
}, [quotas]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setErr(null);
|
||||||
|
setOk(null);
|
||||||
|
try {
|
||||||
|
const toNum = (s: string) => (s === "" ? null : parseInt(s, 10));
|
||||||
|
await api.setUserQuotas(userId, {
|
||||||
|
max_servers: toNum(maxServers),
|
||||||
|
max_cpu_milli: toNum(maxCpu),
|
||||||
|
max_memory_mb: toNum(maxMem),
|
||||||
|
max_storage_gb: toNum(maxStorage),
|
||||||
|
});
|
||||||
|
setOk(t("users_save_ok"));
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !quotas) return <Loading label={t("common:loading")} />;
|
||||||
|
if (error) return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base font-semibold">{t("users_quotas")}</CardTitle></CardHeader>
|
||||||
|
<CardContent><ErrorState error={error} onRetry={reload} /></CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold">{t("users_quotas")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_servers")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={maxServers}
|
||||||
|
onChange={(e) => setMaxServers(e.target.value)}
|
||||||
|
placeholder={t("users_quota_unlimited")}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_cpu")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={maxCpu}
|
||||||
|
onChange={(e) => setMaxCpu(e.target.value)}
|
||||||
|
placeholder={t("users_quota_unlimited")}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_memory")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={maxMem}
|
||||||
|
onChange={(e) => setMaxMem(e.target.value)}
|
||||||
|
placeholder={t("users_quota_unlimited")}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground">{t("users_quota_storage")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={maxStorage}
|
||||||
|
onChange={(e) => setMaxStorage(e.target.value)}
|
||||||
|
placeholder={t("users_quota_unlimited")}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{err}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ok && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{ok}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={saving} size="sm" className="gap-1.5">
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
{t("users_save_btn")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () => void }) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const { data: sessions, error, loading, reload } = useAsync(
|
||||||
|
() => api.listUserSessions(userId),
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
const [revoking, setRevoking] = useState<string | null>(null);
|
||||||
|
const [revokingAll, setRevokingAll] = useState(false);
|
||||||
|
const [revokeOneDlg, setRevokeOneDlg] = useState<string | null>(null);
|
||||||
|
const [revokeAllDlg, setRevokeAllDlg] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [ok, setOk] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleRevokeOne() {
|
||||||
|
if (!revokeOneDlg) return;
|
||||||
|
const hash = revokeOneDlg;
|
||||||
|
setRevoking(hash);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await api.revokeUserSession(userId, hash);
|
||||||
|
setRevokeOneDlg(null);
|
||||||
|
await reload();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
} finally {
|
||||||
|
setRevoking(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeAll() {
|
||||||
|
setRevokingAll(true);
|
||||||
|
setErr(null);
|
||||||
|
setOk(null);
|
||||||
|
try {
|
||||||
|
await api.revokeUserSessions(userId);
|
||||||
|
setRevokeAllDlg(false);
|
||||||
|
setOk(t("users_sessions_revoked"));
|
||||||
|
await reload();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
} finally {
|
||||||
|
setRevokingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
{t("users_sessions")} ({sessions?.length ?? 0})
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
disabled={!sessions || sessions.length === 0 || revokingAll}
|
||||||
|
onClick={() => setRevokeAllDlg(true)}
|
||||||
|
>
|
||||||
|
{revokingAll ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("users_sessions_revoke_all")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{err && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive mb-3">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{err}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ok && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500 mb-3">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
<p>{ok}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<Loading label="" />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorState error={error} onRetry={reload} />
|
||||||
|
) : !sessions || sessions.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("users_no_sessions")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
||||||
|
{sessions.map((s: SessionView) => (
|
||||||
|
<div
|
||||||
|
key={s.token_hash}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border/50 bg-muted/20 pl-3 pr-1 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground truncate block">
|
||||||
|
{s.token_hash.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
<div className="mt-0.5 text-muted-foreground/70">
|
||||||
|
<Clock className="inline h-3 w-3 mr-0.5" />
|
||||||
|
{t("users_session_expires")}: {formatAbsolute(s.expires_at, "en-US")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0 ml-2 mr-0.5"
|
||||||
|
disabled={revoking === s.token_hash}
|
||||||
|
onClick={() => setRevokeOneDlg(s.token_hash)}
|
||||||
|
title={t("users_session_revoke_one")}
|
||||||
|
>
|
||||||
|
{revoking === s.token_hash ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Revoke one confirmation dialog */}
|
||||||
|
<Dialog open={!!revokeOneDlg} onOpenChange={() => setRevokeOneDlg(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("users_session_revoke_one_dlg_title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("users_session_revoke_one_dlg_desc")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setRevokeOneDlg(null)}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleRevokeOne}>
|
||||||
|
{t("users_session_revoke_confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Revoke all confirmation dialog */}
|
||||||
|
<Dialog open={revokeAllDlg} onOpenChange={setRevokeAllDlg}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("users_session_revoke_all_dlg_title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("users_session_revoke_all_dlg_desc")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setRevokeAllDlg(false)}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleRevokeAll}>
|
||||||
|
{t("users_session_revoke_confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DangerZone({
|
||||||
|
user,
|
||||||
|
onChanged,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
user: UserDetail;
|
||||||
|
onChanged: () => void;
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const [dlg, setDlg] = useState<"disable" | "resetPw" | "delete" | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive/30">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold text-destructive">{t("users_danger_zone")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{/* Enable / Disable */}
|
||||||
|
<DangerRow
|
||||||
|
icon={user.disabled ? Power : PowerOff}
|
||||||
|
title={user.disabled ? t("users_danger_enable") : t("users_danger_disable")}
|
||||||
|
desc={user.disabled ? t("users_danger_enable_desc") : t("users_danger_disable_desc")}
|
||||||
|
btnLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
|
||||||
|
btnVariant={user.disabled ? "default" : "destructive"}
|
||||||
|
onAction={() => setDlg("disable")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Reset password */}
|
||||||
|
<DangerRow
|
||||||
|
icon={Key}
|
||||||
|
title={t("users_danger_reset_pw")}
|
||||||
|
desc={user.email
|
||||||
|
? t("users_danger_reset_pw_desc_email", { email: user.email })
|
||||||
|
: t("users_danger_reset_pw_desc")}
|
||||||
|
btnLabel={t("users_danger_reset_pw_btn")}
|
||||||
|
btnVariant="destructive"
|
||||||
|
onAction={() => setDlg("resetPw")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete user */}
|
||||||
|
<DangerRow
|
||||||
|
icon={Trash2}
|
||||||
|
title={t("users_danger_delete")}
|
||||||
|
desc={t("users_danger_delete_desc")}
|
||||||
|
btnLabel={t("users_danger_delete_btn")}
|
||||||
|
btnVariant="destructive"
|
||||||
|
onAction={() => setDlg("delete")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DangerDialogs dlg={dlg} setDlg={setDlg} user={user} onChanged={onChanged} navigate={navigate} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DangerRow({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
btnLabel,
|
||||||
|
btnVariant,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
icon: typeof Power;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
btnLabel: string;
|
||||||
|
btnVariant: "default" | "destructive" | "outline";
|
||||||
|
onAction: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant={btnVariant} size="sm" onClick={onAction} className="gap-1.5">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{btnLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DangerDialogs({
|
||||||
|
dlg,
|
||||||
|
setDlg,
|
||||||
|
user,
|
||||||
|
onChanged,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
dlg: "disable" | "resetPw" | "delete" | null;
|
||||||
|
setDlg: (v: null) => void;
|
||||||
|
user: UserDetail;
|
||||||
|
onChanged: () => void;
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [ok, setOk] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setDlg(null);
|
||||||
|
setErr(null);
|
||||||
|
setOk(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await api.disableUser(user.id, !user.disabled);
|
||||||
|
close();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const r = await api.resetUserPassword(user.id);
|
||||||
|
setOk(t("users_pw_reset_ok", { email: r.email }));
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await api.deleteUser(user.id);
|
||||||
|
navigate("/admin/users");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeError(e));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Disable / Enable dialog */}
|
||||||
|
<Dialog open={dlg === "disable"} onOpenChange={(v) => { if (!v) close(); }}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
{user.disabled ? t("users_danger_enable_dlg_title") : t("users_danger_disable_dlg_title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{user.disabled ? t("users_danger_enable_dlg_desc") : t("users_danger_disable_dlg_desc")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={user.disabled ? "default" : "destructive"}
|
||||||
|
size="sm"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleDisable}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Reset password dialog */}
|
||||||
|
<Dialog open={dlg === "resetPw"} onOpenChange={(v) => { if (!v) close(); }}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Key className="h-5 w-5 text-primary" />
|
||||||
|
{t("users_danger_reset_pw_dlg_title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{user.email
|
||||||
|
? t("users_danger_reset_pw_dlg_desc_email", { email: user.email })
|
||||||
|
: t("users_danger_reset_pw_dlg_desc_no_email")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||||
|
{ok && (
|
||||||
|
<p className="rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
|
||||||
|
<CheckCircle2 className="inline h-4 w-4 mr-1" />
|
||||||
|
{ok}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={loading || ok !== null}
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t("users_danger_reset_pw_confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete user dialog */}
|
||||||
|
<Dialog open={dlg === "delete"} onOpenChange={(v) => { if (!v) close(); }}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
{t("users_danger_delete_dlg_title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("users_danger_delete_dlg_desc")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={close} disabled={loading}>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{t("users_danger_delete_yes")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
Circle,
|
||||||
|
Shield,
|
||||||
|
UserRound,
|
||||||
|
Crown,
|
||||||
|
Server,
|
||||||
|
Mail,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||||
|
import { CreateUserDialog } from "@/components/CreateUserDialog";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAsync } from "@/lib/hooks";
|
||||||
|
import { formatAbsolute } from "@/lib/format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UserView } from "@/lib/types";
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const { t, i18n } = useTranslation("admin");
|
||||||
|
const locale = i18n.language;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [roleFilter, setRoleFilter] = useState("");
|
||||||
|
const [disabledFilter, setDisabledFilter] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(
|
||||||
|
() =>
|
||||||
|
api.listUsers({
|
||||||
|
query: query || undefined,
|
||||||
|
role: (roleFilter || undefined) as "admin" | "user" | undefined,
|
||||||
|
disabled: (disabledFilter || undefined) as "true" | "false" | undefined,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize,
|
||||||
|
}),
|
||||||
|
[query, roleFilter, disabledFilter, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, error, loading, reload } = useAsync(fetchUsers, [fetchUsers]);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(0);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t("users_title")}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("users_subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CreateUserDialog
|
||||||
|
onCreated={(id) => {
|
||||||
|
reload();
|
||||||
|
navigate(`/admin/users/${id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative min-w-[200px] flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t("users_search_placeholder")}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="pl-8 h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(0); }}>
|
||||||
|
<SelectTrigger className="h-9 w-[120px] text-sm">
|
||||||
|
<SelectValue placeholder={t("users_filter_role_all")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{t("users_filter_role_all")}</SelectItem>
|
||||||
|
<SelectItem value="owner">{t("users_role_owner")}</SelectItem>
|
||||||
|
<SelectItem value="admin">{t("users_role_admin")}</SelectItem>
|
||||||
|
<SelectItem value="user">{t("users_role_user")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={disabledFilter} onValueChange={(v) => { setDisabledFilter(v); setPage(0); }}>
|
||||||
|
<SelectTrigger className="h-9 w-[130px] text-sm">
|
||||||
|
<SelectValue placeholder={t("users_filter_status_all")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">{t("users_filter_status_all")}</SelectItem>
|
||||||
|
<SelectItem value="false">{t("users_status_active")}</SelectItem>
|
||||||
|
<SelectItem value="true">{t("users_status_disabled")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="h-9 text-sm">
|
||||||
|
{t("users_search_btn")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loading && !data ? (
|
||||||
|
<Loading />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorState error={error} onRetry={reload} />
|
||||||
|
) : !data || data.users.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t("users_empty_title")}
|
||||||
|
hint={t("users_empty_hint")}
|
||||||
|
>
|
||||||
|
<CreateUserDialog
|
||||||
|
onCreated={(id) => {
|
||||||
|
reload();
|
||||||
|
navigate(`/admin/users/${id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/30 text-left">
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground">{t("users_col_user")}</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden sm:table-cell">{t("users_col_role")}</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden md:table-cell">{t("users_col_servers")}</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">{t("users_col_status")}</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell">{t("users_col_created")}</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-muted-foreground w-0" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.users.map((u: UserView) => (
|
||||||
|
<UserRow key={u.id} user={u} locale={locale} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("users_total_count", { count: data.total })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
>
|
||||||
|
{t("pagination_prev")}
|
||||||
|
</Button>
|
||||||
|
<span className="px-2 text-muted-foreground">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
{t("pagination_next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ user, locale }: { user: UserView; locale: string }) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border/50 hover:bg-muted/20 transition-colors",
|
||||||
|
user.disabled && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link
|
||||||
|
to={`/admin/users/${user.id}`}
|
||||||
|
className="font-medium text-foreground hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</Link>
|
||||||
|
{user.email && (
|
||||||
|
<div className="flex items-center gap-1 mt-0.5 text-xs text-muted-foreground">
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||||
|
user.role === "owner"
|
||||||
|
? "bg-yellow-500/10 text-yellow-600"
|
||||||
|
: user.role === "admin"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{user.role === "owner" ? (
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
) : user.role === "admin" ? (
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<UserRound className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden md:table-cell">
|
||||||
|
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Server className="h-3.5 w-3.5" />
|
||||||
|
{user.server_count}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden lg:table-cell">
|
||||||
|
{user.disabled ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||||
|
<Circle className="h-2 w-2 fill-destructive" />
|
||||||
|
{t("users_status_disabled")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-500">
|
||||||
|
<Circle className="h-2 w-2 fill-emerald-500" />
|
||||||
|
{t("users_status_active")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground hidden lg:table-cell text-xs">
|
||||||
|
{formatAbsolute(user.created_at, locale)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1 text-xs"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={`/admin/users/${user.id}`}>
|
||||||
|
{t("users_view_detail")}
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user