feat(panel): implement user management administration panel with sessions and minecraft link support
This commit is contained in:
28 files changed
+4261
-66
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
|
||||
// Owner it is role=admin with must_change_password=true — Felis has no separate
|
||||
// operator DB role, so an Operator is simply an additional staff admin (migration
|
||||
// 0003). UNLIKE provisionOwner, which upserts the single Owner and resets it on a
|
||||
// Owner it requires must_change_password=true but carries role='admin' (the single
|
||||
// above-admin 'owner' role was added in migration 0011 and is exclusive to the first
|
||||
// account — every subsequent staff is a plain admin). UNLIKE provisionOwner, which
|
||||
// username conflict, this is insert-only: a username already taken returns
|
||||
// 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
|
||||
|
||||
+509
-1
@@ -68,6 +68,8 @@ tags:
|
||||
description: Create / mutate server specs (external face, admin tier).
|
||||
- name: images
|
||||
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:
|
||||
securitySchemes:
|
||||
@@ -326,6 +328,74 @@ components:
|
||||
format: date-time
|
||||
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:
|
||||
# ----------------------------------------------------------------- health ---
|
||||
/healthz:
|
||||
@@ -1785,13 +1855,451 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'409':
|
||||
description: Server is not stopped.
|
||||
description: Submission has already been reviewed.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'503':
|
||||
$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:
|
||||
post:
|
||||
tags: [account]
|
||||
|
||||
+36
-3
@@ -73,6 +73,11 @@ type API struct {
|
||||
// sender. The code is never returned to the client on either path.
|
||||
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 bind). It is optional: when nil the passkey register routes report 503
|
||||
// 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.
|
||||
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
|
||||
// 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
|
||||
@@ -409,6 +421,24 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// 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: "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
|
||||
// unauthenticated on the outer mux; the rest go on an inner mux behind guard
|
||||
// (requireInternal / requireExternal), with Admin routes additionally wrapped in
|
||||
// adminOnly. Because both faces are built from the same table the OpenAPI parity
|
||||
// test reads, the served surface and the documented surface cannot drift apart
|
||||
// without failing the build.
|
||||
// adminOnly, and Owner routes in ownerOnly. Because both faces are built from the
|
||||
// same table the OpenAPI parity test reads, the served surface and the documented
|
||||
// surface cannot drift apart without failing the build.
|
||||
func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
auth := http.NewServeMux()
|
||||
@@ -441,6 +471,9 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
|
||||
continue
|
||||
}
|
||||
h := rt.h
|
||||
if rt.Owner {
|
||||
h = a.ownerOnly(rt.h)
|
||||
}
|
||||
if rt.Admin {
|
||||
h = a.adminOnly(rt.h)
|
||||
}
|
||||
|
||||
+232
-1
@@ -75,6 +75,9 @@ type fakeRepo struct {
|
||||
// just as the PG query does.
|
||||
passkeyCreds map[string]PasskeyCredential
|
||||
passkeyChallenges map[string]*fakePasskeyChallenge
|
||||
// user admin fakes
|
||||
seededUsers []seededUser
|
||||
fakeQuotas map[string]*QuotaView
|
||||
}
|
||||
|
||||
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
|
||||
@@ -157,7 +160,8 @@ func newFakeRepo() *fakeRepo {
|
||||
holds: map[string]fakeDataHold{},
|
||||
passkeyCreds: map[string]PasskeyCredential{},
|
||||
passkeyChallenges: map[string]*fakePasskeyChallenge{},
|
||||
}
|
||||
fakeQuotas: map[string]*QuotaView{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeRepo) ServerBySubdomain(_ context.Context, s string) (*ServerRecord, error) {
|
||||
@@ -631,6 +635,233 @@ func (f *fakeRepo) SetSetting(_ context.Context, key string, value []byte) error
|
||||
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
|
||||
// error, mirroring the Restorer kick-off contract. The real restore Job is
|
||||
// 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.
|
||||
// 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.
|
||||
// An owner implicitly passes this check (the owner role is a superset of admin).
|
||||
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):
|
||||
|
||||
@@ -184,6 +184,7 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
"email": p.Email,
|
||||
"role": p.Role,
|
||||
"is_admin": p.IsAdmin(),
|
||||
"is_owner": p.IsOwner(),
|
||||
"email_verified": emailVerified,
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// 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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -117,6 +117,8 @@ func oasServedFacets(t *testing.T) map[string]oasFacet {
|
||||
switch {
|
||||
case rt.Public:
|
||||
tier = "public"
|
||||
case rt.Owner:
|
||||
tier = "owner"
|
||||
case rt.Admin:
|
||||
tier = "admin"
|
||||
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
|
||||
// break-glass first-run / reset-password path). role is forced to 'admin'; on a
|
||||
// username conflict the email, hash and must_change_password flag are overwritten
|
||||
// while the existing id is preserved, so live sessions referencing it survive a
|
||||
// password reset. The empty email is stored as NULL (users.email is nullable).
|
||||
// break-glass first-run / reset-password path). role is forced to 'owner' —
|
||||
// the platform-level identity one level above admin. On a username conflict the
|
||||
// email, hash and must_change_password flag are overwritten while the existing
|
||||
// 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 {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`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
|
||||
email = NULLIF($3, ''), role = 'admin',
|
||||
email = NULLIF($3, ''), role = 'owner',
|
||||
password_hash = $4, must_change_password = $5`,
|
||||
id, username, email, passwordHash, mustChange)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertOperator mints a NEW Operator (additional staff admin) account
|
||||
// direct-to-Postgres. role is forced to 'admin' — Felis has no separate operator
|
||||
// role, so an Operator is an additional admin row identical in shape to the Owner
|
||||
// (migration 0003). UNLIKE UpsertOwner this is insert-only: a username conflict is
|
||||
// direct-to-Postgres. role is forced to 'admin' — Felis has a separate 'owner'
|
||||
// role (migration 0011) for the single platform owner; Operators are below
|
||||
// that. UNLIKE UpsertOwner this is insert-only: a username conflict is
|
||||
// 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
|
||||
// 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)
|
||||
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)
|
||||
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
||||
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,
|
||||
WhitelistImage,
|
||||
Submission,
|
||||
UserView,
|
||||
UserDetail,
|
||||
CreateUserRequest,
|
||||
PatchUserRequest,
|
||||
QuotaView,
|
||||
QuotaInput,
|
||||
SessionView,
|
||||
} from "../src/lib/types";
|
||||
|
||||
const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
|
||||
|
||||
type AccountID = (typeof ACCOUNT_IDS)[number];
|
||||
type Role = "admin" | "user";
|
||||
type Method = "GET" | "POST" | "DELETE";
|
||||
type AccountID = string;
|
||||
type Role = "admin" | "user" | "owner";
|
||||
type Method = "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||
type CreateError =
|
||||
| "bad_request"
|
||||
| "already_exists"
|
||||
| "subdomain_taken"
|
||||
| "image_not_whitelisted";
|
||||
|
||||
function isAdmin(role: Role): boolean {
|
||||
return role === "admin" || role === "owner";
|
||||
}
|
||||
|
||||
function isOwner(role: Role): boolean {
|
||||
return role === "owner";
|
||||
}
|
||||
|
||||
interface MockAccount {
|
||||
id: AccountID;
|
||||
id: string;
|
||||
role: Role;
|
||||
email: string;
|
||||
linked: boolean;
|
||||
mustChangePassword: boolean;
|
||||
emailVerified: boolean;
|
||||
disabled?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
quota?: QuotaView;
|
||||
sessions?: SessionView[];
|
||||
}
|
||||
|
||||
interface MockServer extends ServerInfo {
|
||||
@@ -180,7 +198,7 @@ function mockBackups(): BackupView[] {
|
||||
function initialState(): MockState {
|
||||
return {
|
||||
accounts: {
|
||||
owner: account("owner", "admin", true, false, false),
|
||||
owner: account("owner", "owner", true, false, false),
|
||||
user: account("user", "user", false, false, false),
|
||||
linked: account("linked", "user", true, false, true),
|
||||
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 {
|
||||
const ids = ACCOUNT_IDS.join("|");
|
||||
const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=(${ids})(?:;|$)`).exec(
|
||||
const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([a-zA-Z0-9_-]+)(?:;|$)`).exec(
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -498,9 +515,13 @@ function clearSessionCookie(res: ServerResponse): void {
|
||||
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();
|
||||
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 {
|
||||
@@ -508,7 +529,8 @@ function identity(accountInfo: MockAccount): Identity {
|
||||
user_id: `mock-${accountInfo.id}`,
|
||||
email: accountInfo.email,
|
||||
role: accountInfo.role,
|
||||
is_admin: accountInfo.role === "admin",
|
||||
is_admin: isAdmin(accountInfo.role),
|
||||
is_owner: isOwner(accountInfo.role),
|
||||
must_change_password: accountInfo.mustChangePassword,
|
||||
email_verified: accountInfo.emailVerified,
|
||||
};
|
||||
@@ -519,11 +541,11 @@ function findServer(state: MockState, name: string): MockServer | null {
|
||||
}
|
||||
|
||||
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 {
|
||||
return accountInfo.role === "admin" || serverInfo.owner === accountInfo.id;
|
||||
return isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id;
|
||||
}
|
||||
|
||||
function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[] {
|
||||
@@ -610,7 +632,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
switch (route(ctx)) {
|
||||
case "POST auth/login": {
|
||||
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) {
|
||||
sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials");
|
||||
return true;
|
||||
@@ -657,14 +679,14 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
switch (route(ctx)) {
|
||||
case "GET updates/window":
|
||||
if (ctx.account.role !== "admin") {
|
||||
if (!isAdmin(ctx.account.role)) {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
sendJSON(ctx.res, 200, ctx.state.updateWindow);
|
||||
return true;
|
||||
case "PUT updates/window": {
|
||||
if (ctx.account.role !== "admin") {
|
||||
if (!isAdmin(ctx.account.role)) {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
@@ -699,7 +721,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
case "GET fleet":
|
||||
// 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.
|
||||
if (ctx.account.role !== "admin") {
|
||||
if (!isAdmin(ctx.account.role)) {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
@@ -714,7 +736,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
|
||||
sendJSON(ctx.res, 200, {
|
||||
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;
|
||||
@@ -795,12 +817,325 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
ctx.res.end();
|
||||
return true;
|
||||
}
|
||||
if (await handleUserRoute(ctx)) return true;
|
||||
if (await handleImageRoute(ctx)) return true;
|
||||
if (await handleSubmissionRoute(ctx)) return true;
|
||||
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> {
|
||||
if (ctx.parts[2] !== "images") return false;
|
||||
|
||||
@@ -812,7 +1147,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// POST /api/v1/images (add image)
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -836,7 +1171,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// DELETE /api/v1/images (remove image)
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -859,7 +1194,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// POST /api/v1/images/build (trigger build)
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -899,7 +1234,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// GET /api/v1/images/build (list builds)
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -909,7 +1244,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// GET /api/v1/images/build/{id} (get build)
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -924,7 +1259,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// 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 (ctx.account.role !== "admin") {
|
||||
if (!isAdmin(ctx.account.role)) {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
@@ -946,7 +1281,7 @@ async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// 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 (ctx.account.role !== "admin") {
|
||||
if (!isAdmin(ctx.account.role)) {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
@@ -1023,7 +1358,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// GET /api/v1/submissions
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -1033,7 +1368,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// POST /api/v1/submissions/{id}/approve
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -1084,7 +1419,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
|
||||
|
||||
// POST /api/v1/submissions/{id}/reject
|
||||
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");
|
||||
return true;
|
||||
}
|
||||
@@ -1156,7 +1491,7 @@ function streamBuildLogs(
|
||||
}
|
||||
|
||||
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");
|
||||
return;
|
||||
}
|
||||
@@ -1285,7 +1620,7 @@ async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServ
|
||||
}
|
||||
|
||||
// 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");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TierProvider } from "@/lib/tier";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { RequireAdmin } from "@/components/RequireAdmin";
|
||||
import { RequireAuth } from "@/components/RequireAuth";
|
||||
import { RequireOwner } from "@/components/RequireOwner";
|
||||
import { Login } from "@/pages/Login";
|
||||
import { ChangePassword } from "@/pages/ChangePassword";
|
||||
import { Dashboard } from "@/pages/Dashboard";
|
||||
@@ -16,6 +17,8 @@ import { Account } from "@/pages/Account";
|
||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
|
||||
import { UsersPage } from "@/pages/admin/UsersPage";
|
||||
import { UserDetailPage } from "@/pages/admin/UserDetailPage";
|
||||
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
|
||||
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
|
||||
|
||||
@@ -61,6 +64,11 @@ export default function App() {
|
||||
<Route path="builds" element={<ImageBuildPage />} />
|
||||
<Route path="submissions" element={<SubmissionsPage />} />
|
||||
<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 path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -137,12 +137,13 @@ function ThemeToggle() {
|
||||
}
|
||||
|
||||
export function AppShell() {
|
||||
const { isAdmin } = useTier();
|
||||
const { isAdmin, isOwner } = useTier();
|
||||
const { t, i18n } = useTranslation("navigation");
|
||||
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
|
||||
// only for admins. isAdmin is fail-closed (false while /me loads or on failure),
|
||||
// so admin sections appear only once identity is confirmed.
|
||||
const sections = visibleSections(isAdmin);
|
||||
// Sections are derived purely from is_admin and is_owner: User-Side always,
|
||||
// Admin-Side only for admins, Owner-Side only for the platform owner. Both
|
||||
// flags are fail-closed (false while /me loads or on failure), so admin and
|
||||
// owner sections appear only once identity is confirmed.
|
||||
const sections = visibleSections(isAdmin, isOwner);
|
||||
|
||||
// Sync document metadata with the active language.
|
||||
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_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_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",
|
||||
"account": "Account",
|
||||
"admin_section": "Admin",
|
||||
"owner_section": "Platform",
|
||||
"admin_users": "Users",
|
||||
"admin_images": "Images",
|
||||
"admin_builds": "Build Pipeline",
|
||||
"admin_submissions": "Submissions",
|
||||
|
||||
@@ -100,5 +100,94 @@
|
||||
"build_import_submission_none": "无匹配的提交或暂未加载",
|
||||
"build_import_submission_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
|
||||
"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": "我的提交",
|
||||
"account": "账户",
|
||||
"admin_section": "管理",
|
||||
"owner_section": "平台",
|
||||
"admin_users": "用户管理",
|
||||
"admin_images": "镜像",
|
||||
"admin_builds": "构建流水线",
|
||||
"admin_submissions": "审核提交",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BanlistResult,
|
||||
Build,
|
||||
CreateServerRequest,
|
||||
CreateUserRequest,
|
||||
FleetServer,
|
||||
Identity,
|
||||
KickResult,
|
||||
@@ -13,8 +14,14 @@ import type {
|
||||
LinkStatus,
|
||||
LoginResult,
|
||||
BindResult,
|
||||
PatchUserRequest,
|
||||
PlayersResult,
|
||||
QuotaInput,
|
||||
QuotaView,
|
||||
ServerInfo,
|
||||
SessionView,
|
||||
UserDetail,
|
||||
UserView,
|
||||
WhitelistImage,
|
||||
WhitelistResult,
|
||||
Submission,
|
||||
@@ -339,6 +346,72 @@ export const api = {
|
||||
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/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,
|
||||
Server,
|
||||
UserRound,
|
||||
Users,
|
||||
Boxes,
|
||||
Cpu,
|
||||
ClipboardCheck,
|
||||
@@ -30,11 +31,14 @@ export interface NavItem {
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
id: "user" | "admin";
|
||||
id: "user" | "admin" | "owner";
|
||||
/** Section heading key; null renders no heading (User-Side flat list). */
|
||||
titleKey: string | null;
|
||||
/** When true the section is shown only to admins (is_admin === true). */
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -43,6 +47,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
id: "user",
|
||||
titleKey: null,
|
||||
adminOnly: false,
|
||||
ownerOnly: false,
|
||||
items: [
|
||||
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/servers", key: "my_servers", icon: Server },
|
||||
@@ -54,6 +59,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
id: "admin",
|
||||
titleKey: "admin_section",
|
||||
adminOnly: true,
|
||||
ownerOnly: false,
|
||||
items: [
|
||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.
|
||||
* 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
|
||||
* 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[] {
|
||||
return NAV_SECTIONS.filter((s) => !s.adminOnly || isAdmin);
|
||||
export function visibleSections(isAdmin: boolean, isOwner: boolean): NavSection[] {
|
||||
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.
|
||||
|
||||
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
|
||||
* navigation after the context has settled (login → refresh → redirect). */
|
||||
refresh: () => Promise<void>;
|
||||
@@ -42,6 +45,7 @@ const TierContext = createContext<TierState>({
|
||||
identity: null,
|
||||
loading: true,
|
||||
isAdmin: false,
|
||||
isOwner: false,
|
||||
unauthenticated: false,
|
||||
mustChangePassword: false,
|
||||
refresh: async () => {},
|
||||
@@ -82,9 +86,13 @@ export function TierProvider({ children }: { children: ReactNode }) {
|
||||
}, [refresh]);
|
||||
|
||||
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 (
|
||||
<TierContext.Provider value={{ ...state, refresh }}>
|
||||
<TierContext.Provider value={{ ...state, isOwner, refresh }}>
|
||||
{children}
|
||||
</TierContext.Provider>
|
||||
);
|
||||
|
||||
+67
-2
@@ -208,8 +208,11 @@ export interface ApiError {
|
||||
export interface Identity {
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: "user" | "admin";
|
||||
role: "user" | "admin" | "owner";
|
||||
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
|
||||
* 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
|
||||
@@ -224,7 +227,7 @@ export interface Identity {
|
||||
* change-password card before any other surface. */
|
||||
export interface LoginResult {
|
||||
user_id: string;
|
||||
role: "user" | "admin";
|
||||
role: "user" | "admin" | "owner";
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
@@ -274,3 +277,65 @@ export interface UpdateWindow {
|
||||
start: 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