feat(panel): implement user management administration panel with sessions and minecraft link support

This commit is contained in:
Lemon-miaow committed 2026-07-04 04:08:14 +08:00
1 parent 83e57b4c45
commit 3347cc05d5
28 files changed
+4261 -66

No files matched your search

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