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 (