diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d2920b7..e6a5ea9 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Navigate, Route, BrowserRouter, Routes } from "react-router-dom"; +import { AdminRoute } from "./auth/AdminRoute"; import { AuthProvider } from "./auth/AuthProvider"; import { ProtectedRoute } from "./auth/ProtectedRoute"; import { AppShell } from "./components/layout/AppShell"; @@ -8,6 +9,7 @@ import { LoginPage } from "./features/auth/LoginPage"; import { SupportCodesPage } from "./features/codes/SupportCodesPage"; import { MachinesPage } from "./features/machines/MachinesPage"; import { SessionsPage } from "./features/sessions/SessionsPage"; +import { UsersPage } from "./features/users/UsersPage"; const queryClient = new QueryClient({ defaultOptions: { @@ -31,7 +33,11 @@ export function App() { } /> } /> } /> - {/* Users lands in a later pass. */} + {/* Users is admin-only: AdminRoute renders an access-denied + panel for non-admins instead of the view. */} + }> + } /> + } /> diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts index c0f2cf2..fb5d652 100644 --- a/dashboard/src/api/index.ts +++ b/dashboard/src/api/index.ts @@ -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"; diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts index a7723b3..4ce7021 100644 --- a/dashboard/src/api/types.ts +++ b/dashboard/src/api/types.ts @@ -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 = { + 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; diff --git a/dashboard/src/api/users.ts b/dashboard/src/api/users.ts new file mode 100644 index 0000000..52c3a40 --- /dev/null +++ b/dashboard/src/api/users.ts @@ -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 { + return http.get("/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 { + return http.post("/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 { + return http.put(`/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 { + return http.put(`/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 { + return http.del(`/api/users/${encodeURIComponent(id)}`); +} diff --git a/dashboard/src/auth/AdminRoute.tsx b/dashboard/src/auth/AdminRoute.tsx new file mode 100644 index 0000000..4d8fd85 --- /dev/null +++ b/dashboard/src/auth/AdminRoute.tsx @@ -0,0 +1,56 @@ +import { Link, Outlet } from "react-router-dom"; +import { Panel } from "../components/ui/Panel"; +import { useAuth } from "./AuthContext"; + +/** + * Route gate for admin-only sections (the Users plane). Sits inside + * ProtectedRoute, so the user is already authenticated here — this only checks + * the admin role. + * + * A non-admin who navigates to an admin route sees a calm, explicit + * access-denied panel (NOT a redirect loop and NOT a 403 toast storm). The + * server remains the real authority: the underlying /api/users calls are + * admin-gated server-side, so this is defense-in-depth plus correct UX. + */ +export function AdminRoute() { + const { isAdmin } = useAuth(); + + if (!isAdmin) { + return ( +
+
+ +
+ +

Admins only

+

+ User management is restricted to administrators. Your account + does not have admin access. If you need it, ask an administrator + to update your role. +

+ + Back to Machines + +
+
+
+
+ ); + } + + return ; +} diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx index 4686866..059bb47 100644 --- a/dashboard/src/components/layout/Sidebar.tsx +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -1,5 +1,6 @@ import { NavLink } from "react-router-dom"; import type { ComponentType, SVGProps } from "react"; +import { useAuth } from "../../auth/AuthContext"; import { CodesIcon, MachinesIcon, @@ -13,16 +14,29 @@ interface NavItem { Icon: ComponentType>; /** Pass-1 stubs are disabled until their views land in later passes. */ enabled: boolean; + /** Only render for admins (the underlying route is admin-gated). */ + adminOnly?: boolean; } const NAV: NavItem[] = [ { to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true }, { to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: true }, { to: "/codes", label: "Codes", Icon: CodesIcon, enabled: true }, - { to: "/users", label: "Users", Icon: UsersIcon, enabled: false }, + { + to: "/users", + label: "Users", + Icon: UsersIcon, + enabled: true, + adminOnly: true, + }, ]; export function Sidebar() { + const { isAdmin } = useAuth(); + // Hide admin-only items from non-admins entirely (the route also gates them, + // and the API is admin-gated server-side — this keeps the UX honest). + const items = NAV.filter((item) => !item.adminOnly || isAdmin); + return (