feat(dashboard): GuruConnect v2 Users admin view
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m43s
Build and Test / Build Agent (Windows) (push) Successful in 8m48s
Build and Test / Security Audit (push) Successful in 4m38s
Build and Test / Build Summary (push) Has been skipped

Admin-only user management: list, create, edit role/permissions/status,
reset password, and disable/delete, against the v2 users API.

- Admin-gated three ways: AdminRoute on /users (calm access-denied panel
  for non-admins, no redirect loop or data fetch), Sidebar hides the nav
  item, and every mutation relies on the server AdminUser 403 as the real
  authority. isAdmin is derived from the server-validated user, not the
  client token.
- Users table: role badge (admin/operator/viewer), permissions summary,
  enabled/disabled status, created, last-login. Sticky header, skeleton,
  empty/error states. Self row tagged "You".
- Create/edit use the real roles and permission strings
  (view/control/transfer/manage_users/manage_clients); admin permissions
  are server-implicit and shown locked. Passwords: typed or Web Crypto
  generated (rejection-sampled, copy-once reveal), type=password +
  autoComplete=new-password, cleared from state on open/close/success,
  never logged/persisted/in-URL; blank on edit means unchanged.
- Self-lockout guards: cannot disable, delete, or demote your own admin
  account (controls disabled + submit-handler checks, matched on the
  authoritative user id). Server mirrors self-disable/self-delete; the
  self-demotion guard is client-side (server todo filed).
- useUpdateUser sequences user-update then permissions-set; invalidates
  ["users"] on settled so the table reconciles after a partial failure,
  with an actionable message if only permissions failed.

Passed Code Review (no blockers after fixes) and local gates
(tsc/lint/build green). Completes the v2 dashboard view set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:18:40 -07:00
parent 664f33d5ab
commit 96b4fd7721
17 changed files with 1827 additions and 3 deletions

View File

@@ -4,3 +4,4 @@ export * as authApi from "./auth";
export * as codesApi from "./codes";
export * as machinesApi from "./machines";
export * as stubsApi from "./stubs";
export * as usersApi from "./users";

View File

@@ -15,6 +15,39 @@ export type Permission =
| "manage_users"
| "manage_clients";
/**
* The canonical role set the server accepts (server/src/api/users.rs
* `valid_roles`). The Users admin editor must offer exactly these — sending any
* other value is a 400.
*/
export const ROLES: readonly Role[] = ["admin", "operator", "viewer"] as const;
/**
* The canonical permission set the server accepts (server/src/api/users.rs
* `valid_permissions`). These are the exact strings the rest of the app checks
* (`view`/`control` gate viewer-token minting; `manage_users` gates the admin
* plane). The permission editor must use these — an invented string is a 400.
*/
export const PERMISSIONS: readonly Permission[] = [
"view",
"control",
"transfer",
"manage_users",
"manage_clients",
] as const;
/**
* The server's role-default permissions (server/src/api/users.rs, the `match
* request.role` block). When a user is created without an explicit permission
* list the server seeds these. The create form mirrors them so the checkboxes
* preview exactly what the server will store.
*/
export const ROLE_DEFAULT_PERMISSIONS: Record<Role, Permission[]> = {
admin: ["view", "control", "transfer", "manage_users", "manage_clients"],
operator: ["view", "control", "transfer"],
viewer: ["view"],
};
export interface User {
id: string;
username: string;
@@ -24,6 +57,52 @@ export interface User {
permissions: (Permission | string)[];
}
/**
* Full admin-plane view of a user. Mirrors `api::users::UserInfo`
* (server/src/api/users.rs) exactly — every field the list/create/get/update
* endpoints return. The password hash is NEVER serialized by the server, so it
* has no place in this type. `enabled` is the server's active/disabled flag
* (a disabled user cannot log in). `email` and `last_login` are nullable.
*/
export interface UserAdmin {
id: string;
username: string;
email: string | null;
role: Role | string;
enabled: boolean;
created_at: string; // RFC3339
last_login: string | null; // RFC3339
permissions: (Permission | string)[];
}
/**
* Body for `POST /api/users`. Mirrors `api::users::CreateUserRequest`.
* `password` is required (server enforces >= 8 chars). `permissions` is
* optional: when omitted the server seeds role-default permissions, so the
* create UI sends it only when the admin overrides the defaults.
*/
export interface CreateUserRequest {
username: string;
password: string;
email?: string | null;
role: Role | string;
permissions?: (Permission | string)[];
}
/**
* Body for `PUT /api/users/:id`. Mirrors `api::users::UpdateUserRequest`.
* `role` and `enabled` are required (the server always re-applies them).
* `password`, when present, sets a new password (server enforces >= 8 chars);
* omit it to leave the password unchanged. Permissions are NOT updated here —
* they go through the dedicated permissions endpoint.
*/
export interface UpdateUserRequest {
email?: string | null;
role: Role | string;
enabled: boolean;
password?: string;
}
export interface LoginResponse {
token: string;
user: User;

View File

@@ -0,0 +1,58 @@
import { http } from "./client";
import type {
CreateUserRequest,
Permission,
UpdateUserRequest,
UserAdmin,
} from "./types";
// Admin-plane user management. Every endpoint here is admin-gated server-side
// (the `AdminUser` extractor in server/src/auth/mod.rs returns 403 for a
// non-admin). The dashboard mirrors that gate so a non-admin never reaches
// these calls, but the server is the authority.
/** GET /api/users — list every user (admin only). */
export function listUsers(signal?: AbortSignal): Promise<UserAdmin[]> {
return http.get<UserAdmin[]>("/api/users", signal);
}
/**
* POST /api/users — create a user (admin only). Returns the created user.
* The plaintext password is sent in the body but NEVER echoed back in the
* response (the server's UserInfo has no password field).
*/
export function createUser(body: CreateUserRequest): Promise<UserAdmin> {
return http.post<UserAdmin>("/api/users", body);
}
/**
* PUT /api/users/:id — update role / enabled / email, and optionally set a new
* password (admin only). Permissions are NOT changed here — use setPermissions.
*/
export function updateUser(
id: string,
body: UpdateUserRequest,
): Promise<UserAdmin> {
return http.put<UserAdmin>(`/api/users/${encodeURIComponent(id)}`, body);
}
/**
* PUT /api/users/:id/permissions — replace a user's permission set (admin
* only). Returns 200 with no body.
*/
export function setUserPermissions(
id: string,
permissions: (Permission | string)[],
): Promise<void> {
return http.put<void>(`/api/users/${encodeURIComponent(id)}/permissions`, {
permissions,
});
}
/**
* DELETE /api/users/:id — permanently delete a user (admin only). The server
* refuses to delete the caller's own account (400). Returns 204.
*/
export function deleteUser(id: string): Promise<void> {
return http.del<void>(`/api/users/${encodeURIComponent(id)}`);
}