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:
@@ -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<SVGProps<SVGSVGElement>>;
|
||||
/** 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 (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__brand">
|
||||
@@ -36,7 +50,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
<nav className="sidebar__nav" aria-label="Primary">
|
||||
<span className="sidebar__section">Operations</span>
|
||||
{NAV.map(({ to, label, Icon, enabled }) =>
|
||||
{items.map(({ to, label, Icon, enabled }) =>
|
||||
enabled ? (
|
||||
<NavLink
|
||||
key={to}
|
||||
|
||||
@@ -135,3 +135,38 @@ export function PlusIcon(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeOffIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a18 18 0 0 1-2.16 3.19M6.6 6.6A18 18 0 0 0 2 11s3.5 7 10 7a9 9 0 0 0 5.4-1.6" />
|
||||
<path d="m9.5 9.5a3 3 0 0 0 4.2 4.2M3 3l18 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShuffleIcon(props: IconProps) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,55 @@
|
||||
|
||||
export type StatusTone = "ok" | "warn" | "bad" | "neutral";
|
||||
|
||||
/** Badge tones available to features (StatusTone plus the brand `accent`). */
|
||||
export type BadgeTone = StatusTone | "accent";
|
||||
|
||||
/**
|
||||
* Map a user role to a badge tone. `admin` is the elevated, distinct tone and
|
||||
* gets the brand `accent` so it reads as "privileged" at a glance; `operator`
|
||||
* is a normal active role (`ok`); `viewer` is the least-privileged, muted
|
||||
* (`neutral`). An unknown role falls back to `neutral`.
|
||||
*/
|
||||
export function roleTone(role: string): BadgeTone {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "accent";
|
||||
case "operator":
|
||||
return "ok";
|
||||
case "viewer":
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
/** Title-case label for a role; passes unknown roles through verbatim. */
|
||||
export function roleLabel(role: string): string {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "Admin";
|
||||
case "operator":
|
||||
return "Operator";
|
||||
case "viewer":
|
||||
return "Viewer";
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a user's enabled flag to a status tone. An enabled account is healthy
|
||||
* (`ok`); a disabled one is a deliberate block and reads as `bad` so it stands
|
||||
* out in the table (a disabled user is an exception worth seeing).
|
||||
*/
|
||||
export function userStatusTone(enabled: boolean): StatusTone {
|
||||
return enabled ? "ok" : "bad";
|
||||
}
|
||||
|
||||
/** Human label for a user's enabled flag. */
|
||||
export function userStatusLabel(enabled: boolean): string {
|
||||
return enabled ? "Active" : "Disabled";
|
||||
}
|
||||
|
||||
/** Map a machine `status` string to a tone. */
|
||||
export function machineTone(status: string): StatusTone {
|
||||
return status === "online" ? "ok" : "bad";
|
||||
|
||||
Reference in New Issue
Block a user