feat(dashboard): GuruConnect v2 Users admin view
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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
dashboard/src/api/users.ts
Normal file
58
dashboard/src/api/users.ts
Normal 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)}`);
|
||||
}
|
||||
Reference in New Issue
Block a user