From 3347cc05d5f0f344e26e74666a11675eec226b07 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sat, 4 Jul 2026 04:08:14 +0800 Subject: [PATCH] feat(panel): implement user management administration panel with sessions and minecraft link support --- cmd/felis/breakglass.go | 6 +- docs/openapi.yaml | 510 +++++++++- internal/api/api.go | 39 +- internal/api/api_test.go | 237 ++++- internal/api/auth.go | 11 +- internal/api/handlers_user.go | 1 + internal/api/handlers_users.go | 569 +++++++++++ internal/api/middleware.go | 17 +- internal/api/openapi_test.go | 2 + internal/api/pgrepo.go | 509 +++++++++- internal/api/repo.go | 147 +++ .../store/migrations/0010_user_management.sql | 40 + internal/store/migrations/0011_owner_role.sql | 18 + panel/dev/mockApi.ts | 401 +++++++- panel/src/App.tsx | 8 + panel/src/components/AppShell.tsx | 11 +- panel/src/components/CreateUserDialog.tsx | 189 ++++ panel/src/components/RequireOwner.tsx | 17 + panel/src/i18n/resources/en-US/admin.json | 91 +- .../src/i18n/resources/en-US/navigation.json | 2 + panel/src/i18n/resources/zh-CN/admin.json | 91 +- .../src/i18n/resources/zh-CN/navigation.json | 2 + panel/src/lib/api.ts | 73 ++ panel/src/lib/nav.ts | 28 +- panel/src/lib/tier.tsx | 10 +- panel/src/lib/types.ts | 69 +- panel/src/pages/admin/UserDetailPage.tsx | 949 ++++++++++++++++++ panel/src/pages/admin/UsersPage.tsx | 284 ++++++ 28 files changed, 4263 insertions(+), 68 deletions(-) create mode 100644 internal/api/handlers_users.go create mode 100644 internal/store/migrations/0010_user_management.sql create mode 100644 internal/store/migrations/0011_owner_role.sql create mode 100644 panel/src/components/CreateUserDialog.tsx create mode 100644 panel/src/components/RequireOwner.tsx create mode 100644 panel/src/pages/admin/UserDetailPage.tsx create mode 100644 panel/src/pages/admin/UsersPage.tsx diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 531d71f..0b32f73 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d8922d1..3852a0a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/internal/api/api.go b/internal/api/api.go index 5c8000d..af691e2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b715e92..2dd8aa0 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 @@ -155,9 +158,10 @@ func newFakeRepo() *fakeRepo { otps: map[string]*fakeEmailOTP{}, blacklist: map[string]bool{}, holds: map[string]fakeDataHold{}, - passkeyCreds: map[string]PasskeyCredential{}, - passkeyChallenges: map[string]*fakePasskeyChallenge{}, - } + 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). diff --git a/internal/api/auth.go b/internal/api/auth.go index f1b218a..cba3738 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -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): diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index ea289d4..7cfca40 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -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 diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go new file mode 100644 index 0000000..8773bb9 --- /dev/null +++ b/internal/api/handlers_users.go @@ -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 +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 710c82f..0759b94 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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 diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index e91bcaa..d1fed99 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -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: diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 988b12d..0a7d672 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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 +} diff --git a/internal/api/repo.go b/internal/api/repo.go index 5b83e91..90bcefe 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -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"` } diff --git a/internal/store/migrations/0010_user_management.sql b/internal/store/migrations/0010_user_management.sql new file mode 100644 index 0000000..b003487 --- /dev/null +++ b/internal/store/migrations/0010_user_management.sql @@ -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; diff --git a/internal/store/migrations/0011_owner_role.sql b/internal/store/migrations/0011_owner_role.sql new file mode 100644 index 0000000..40dfa00 --- /dev/null +++ b/internal/store/migrations/0011_owner_role.sql @@ -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 = ''; + +ALTER TYPE user_role ADD VALUE 'owner'; diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 7d699a3..73b12bc 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -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(req: IncomingMessage): Promise { } 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 { 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 { async function handleSession(ctx: SessionContext): Promise { 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 { 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 { // 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 { 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 { + 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(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(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(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 { if (ctx.parts[2] !== "images") return false; @@ -812,7 +1147,7 @@ async function handleImageRoute(ctx: SessionContext): Promise { // 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 { // 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 { // 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 { // 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 { // 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 { // 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 { // 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 { // 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 { // 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 { // 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 { - 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; } diff --git a/panel/src/App.tsx b/panel/src/App.tsx index d60a009..b5f0e13 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -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() { } /> } /> } /> + {/* Owner-gated: user management (one level above admin). */} + }> + } /> + } /> + } /> diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index dd7d82d..f50fff7 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -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(() => { diff --git a/panel/src/components/CreateUserDialog.tsx b/panel/src/components/CreateUserDialog.tsx new file mode 100644 index 0000000..83417a2 --- /dev/null +++ b/panel/src/components/CreateUserDialog.tsx @@ -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(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 ( + { setOpen(v); if (!v) reset(); }}> + + + + + + {t("users_create_title")} + + {t("users_create_desc")} + + +
+ {/* Username */} +
+ + setUsername(e.target.value)} + placeholder={t("users_create_username_placeholder")} + className="h-9 text-sm" + autoFocus + /> +
+ + {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + className="h-9 text-sm" + /> +
+ + {/* Role */} +
+ + +
+ + {/* Password */} +
+ + setPassword(e.target.value)} + placeholder="min. 8 characters" + className="h-9 text-sm" + /> +
+ + {/* Must change password toggle */} + + + {err && ( +

+ {err} +

+ )} + + + + +
+
+
+ ); +} diff --git a/panel/src/components/RequireOwner.tsx b/panel/src/components/RequireOwner.tsx new file mode 100644 index 0000000..f68fcc3 --- /dev/null +++ b/panel/src/components/RequireOwner.tsx @@ -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 ; + } + if (!isOwner) { + return ; + } + return ; +} diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index f0cafb9..2dafc96 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -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" } \ No newline at end of file diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json index baf262f..cdbaaa5 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 0d495b6..5b9ea52 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -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": "是的,永久删除" } \ No newline at end of file diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index 855bb99..bc6f189 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -4,6 +4,8 @@ "my_submissions": "我的提交", "account": "账户", "admin_section": "管理", + "owner_section": "平台", + "admin_users": "用户管理", "admin_images": "镜像", "admin_builds": "构建流水线", "admin_submissions": "审核提交", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index f25030f..34d0d88 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -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("GET", "/updates/window"), setUpdateWindow: (window: UpdateWindow) => request("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("GET", `/users/${id}`), + + createUser: (req: CreateUserRequest) => + request("POST", "/users", req), + + patchUser: (id: string, patch: PatchUserRequest) => + request("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("GET", `/users/${id}/quotas`), + + setUserQuotas: (id: string, quotas: QuotaInput) => + request("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)}`, + ), }; /** diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts index c3cb974..504674b 100644 --- a/panel/src/lib/nav.ts +++ b/panel/src/lib/nav.ts @@ -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; + }); } diff --git a/panel/src/lib/tier.tsx b/panel/src/lib/tier.tsx index bf62ebb..3c452fe 100644 --- a/panel/src/lib/tier.tsx +++ b/panel/src/lib/tier.tsx @@ -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; @@ -42,6 +45,7 @@ const TierContext = createContext({ 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 ( - + {children} ); diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 8398692..945d30c 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -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; +} diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx new file mode 100644 index 0000000..01498bb --- /dev/null +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -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 ; + if (error) return ; + if (!user) return ; + + return ( +
+ {/* Back link */} + + + {/* Header */} +
+
+
+ +
+
+

{user.username}

+
+ {user.email && ( + + + {user.email} + {user.email_verified && ( + + )} + + )} + + + {formatAbsolute(user.created_at, locale)} + +
+
+
+
+ {user.disabled ? ( + + + {t("users_status_disabled")} + + ) : ( + + + {t("users_status_active")} + + )} + + {user.role === "owner" ? ( + + ) : ( + + )} + {user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")} + +
+
+ +
+ {/* Edit profile */} + + {/* Linked accounts */} + + {/* Quotas */} + + {/* Sessions */} + +
+ + {/* Danger zone */} + +
+ ); +} + +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(null); + const [ok, setOk] = useState(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 ( + + + {t("users_edit_profile")} + + +
+ + setUsername(e.target.value)} + className="h-9 text-sm" + /> +
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + className="h-9 text-sm" + /> +
+ {!isSelf && ( +
+ + +
+ )} + + {err && ( +
+ +

{err}

+
+ )} + {ok && ( +
+ +

{ok}

+
+ )} + + +
+
+ ); +} + +function LinkedAccountsCard({ user, onChanged }: { user: UserDetail; onChanged: () => void }) { + const { t } = useTranslation("admin"); + const accounts = user.linked_accounts ?? []; + const [unlinking, setUnlinking] = useState(null); + const [unlinkDlg, setUnlinkDlg] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [newUUID, setNewUUID] = useState(""); + const [newSource, setNewSource] = useState("mojang"); + const [adding, setAdding] = useState(false); + const [err, setErr] = useState(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 ( + <> + + + + {t("users_linked_accounts")} ({accounts.length}) + + + + + {/* Add form */} + {showAdd && ( +
+
+
+ + setNewUUID(e.target.value)} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + className="h-8 text-xs font-mono" + /> +
+
+ + +
+
+ {err && ( +

{err}

+ )} + +
+ )} + + {accounts.length === 0 ? ( +

{t("users_no_linked")}

+ ) : ( +
+ {accounts.map((acc) => ( +
+
+ {acc.mc_uuid} +
+ {acc.auth_source} · {formatAbsolute(acc.verified_at, "en-US")} +
+
+ +
+ ))} +
+ )} +
+
+ + {/* Unlink confirmation dialog */} + setUnlinkDlg(null)}> + + + {t("users_unlink_dlg_title")} + {t("users_unlink_dlg_desc")} + + + + + + + + + ); +} + +function QuotasCard({ userId }: { userId: string }) { + const { t } = useTranslation("admin"); + const { data: quotas, error, loading, reload } = useAsync( + () => api.getUserQuotas(userId), + [userId], + ); + + const [maxServers, setMaxServers] = useState(""); + const [maxCpu, setMaxCpu] = useState(""); + const [maxMem, setMaxMem] = useState(""); + const [maxStorage, setMaxStorage] = useState(""); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + const [ok, setOk] = useState(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 ; + if (error) return ( + + {t("users_quotas")} + + + ); + + return ( + + + {t("users_quotas")} + + +
+
+ + setMaxServers(e.target.value)} + placeholder={t("users_quota_unlimited")} + className="h-9 text-sm" + /> +
+
+ + setMaxCpu(e.target.value)} + placeholder={t("users_quota_unlimited")} + className="h-9 text-sm" + /> +
+
+ + setMaxMem(e.target.value)} + placeholder={t("users_quota_unlimited")} + className="h-9 text-sm" + /> +
+
+ + setMaxStorage(e.target.value)} + placeholder={t("users_quota_unlimited")} + className="h-9 text-sm" + /> +
+
+ + {err && ( +
+ +

{err}

+
+ )} + {ok && ( +
+ +

{ok}

+
+ )} + + +
+
+ ); +} + +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(null); + const [revokingAll, setRevokingAll] = useState(false); + const [revokeOneDlg, setRevokeOneDlg] = useState(null); + const [revokeAllDlg, setRevokeAllDlg] = useState(false); + const [err, setErr] = useState(null); + const [ok, setOk] = useState(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 ( + <> + + + + {t("users_sessions")} ({sessions?.length ?? 0}) + + + + + {err && ( +
+ +

{err}

+
+ )} + {ok && ( +
+ +

{ok}

+
+ )} + {loading ? ( + + ) : error ? ( + + ) : !sessions || sessions.length === 0 ? ( +

{t("users_no_sessions")}

+ ) : ( +
+ {sessions.map((s: SessionView) => ( +
+
+ + {s.token_hash.slice(0, 20)}... + +
+ + {t("users_session_expires")}: {formatAbsolute(s.expires_at, "en-US")} +
+
+ +
+ ))} +
+ )} +
+
+ + {/* Revoke one confirmation dialog */} + setRevokeOneDlg(null)}> + + + {t("users_session_revoke_one_dlg_title")} + {t("users_session_revoke_one_dlg_desc")} + + + + + + + + + {/* Revoke all confirmation dialog */} + + + + {t("users_session_revoke_all_dlg_title")} + {t("users_session_revoke_all_dlg_desc")} + + + + + + + + + ); +} + +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 ( + + + {t("users_danger_zone")} + + + {/* Enable / Disable */} + setDlg("disable")} + /> + + {/* Reset password */} + setDlg("resetPw")} + /> + + {/* Delete user */} + setDlg("delete")} + /> + + + + + ); +} + +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 ( +
+
+

{title}

+

{desc}

+
+ +
+ ); +} + +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(null); + const [ok, setOk] = useState(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 */} + { if (!v) close(); }}> + + + + + {user.disabled ? t("users_danger_enable_dlg_title") : t("users_danger_disable_dlg_title")} + + + {user.disabled ? t("users_danger_enable_dlg_desc") : t("users_danger_disable_dlg_desc")} + + + {err &&

{err}

} + + + + +
+
+ + {/* Reset password dialog */} + { if (!v) close(); }}> + + + + + {t("users_danger_reset_pw_dlg_title")} + + + {user.email + ? t("users_danger_reset_pw_dlg_desc_email", { email: user.email }) + : t("users_danger_reset_pw_dlg_desc_no_email")} + + + {err &&

{err}

} + {ok && ( +

+ + {ok} +

+ )} + + + + +
+
+ + {/* Delete user dialog */} + { if (!v) close(); }}> + + + + + {t("users_danger_delete_dlg_title")} + + + {t("users_danger_delete_dlg_desc")} + + + {err &&

{err}

} + + + + +
+
+ + ); +} diff --git a/panel/src/pages/admin/UsersPage.tsx b/panel/src/pages/admin/UsersPage.tsx new file mode 100644 index 0000000..c86d25f --- /dev/null +++ b/panel/src/pages/admin/UsersPage.tsx @@ -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 ( +
+ {/* Header */} +
+
+ +
+

{t("users_title")}

+

{t("users_subtitle")}

+
+
+ { + reload(); + navigate(`/admin/users/${id}`); + }} + /> +
+ + {/* Filters */} + + +
+
+ + setQuery(e.target.value)} + className="pl-8 h-9 text-sm" + /> +
+ + + +
+
+
+ + {/* Table */} + {loading && !data ? ( + + ) : error ? ( + + ) : !data || data.users.length === 0 ? ( + + { + reload(); + navigate(`/admin/users/${id}`); + }} + /> + + ) : ( + <> + + +
+ + + + + + + + + + + + {data.users.map((u: UserView) => ( + + ))} + +
{t("users_col_user")}{t("users_col_role")}{t("users_col_servers")}{t("users_col_status")}{t("users_col_created")} +
+
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {t("users_total_count", { count: data.total })} + +
+ + + {page + 1} / {totalPages} + + +
+
+ )} + + )} +
+ ); +} + +function UserRow({ user, locale }: { user: UserView; locale: string }) { + const { t } = useTranslation("admin"); + + return ( + + + + {user.username} + + {user.email && ( +
+ + {user.email} +
+ )} + + + + {user.role === "owner" ? ( + + ) : user.role === "admin" ? ( + + ) : ( + + )} + {user.role === "owner" ? t("users_role_owner") : user.role === "admin" ? t("users_role_admin") : t("users_role_user")} + + + + + + {user.server_count} + + + + {user.disabled ? ( + + + {t("users_status_disabled")} + + ) : ( + + + {t("users_status_active")} + + )} + + + {formatAbsolute(user.created_at, locale)} + + + + + + ); +}