Files
guru-connect/dashboard/src/features/users/UsersPage.tsx
Mike Swanson 96b4fd7721
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
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>
2026-05-30 14:18:40 -07:00

304 lines
8.8 KiB
TypeScript

import { useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import type { UserAdmin } from "../../api/types";
import { useAuth } from "../../auth/AuthContext";
import { PageHeader } from "../../components/layout/PageHeader";
import {
EditIcon,
PlusIcon,
RefreshIcon,
SearchIcon,
TrashIcon,
} from "../../components/layout/icons";
import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button";
import { Input } from "../../components/ui/Input";
import { Panel } from "../../components/ui/Panel";
import { EmptyState, ErrorState } from "../../components/ui/States";
import { StatusDot } from "../../components/ui/StatusDot";
import {
roleLabel,
roleTone,
userStatusLabel,
userStatusTone,
} from "../../components/ui/status";
import { Table, type Column } from "../../components/ui/Table";
import { TableSkeleton } from "../../components/ui/TableSkeleton";
import { absoluteTime, relativeTime } from "../../lib/time";
import { CreateUserModal } from "./CreateUserModal";
import { DeleteUserDialog } from "./DeleteUserDialog";
import { EditUserModal } from "./EditUserModal";
import { useUsers } from "./hooks";
import "./users.css";
/** Compact permissions summary: admins are implicit-all; others list the set. */
function permissionsSummary(user: UserAdmin): string {
if (user.role === "admin") return "All permissions";
if (user.permissions.length === 0) return "No permissions";
return user.permissions.join(", ");
}
export function UsersPage() {
const { user: currentUser } = useAuth();
const usersQuery = useUsers();
const [filter, setFilter] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editFor, setEditFor] = useState<UserAdmin | null>(null);
const [deleteFor, setDeleteFor] = useState<UserAdmin | null>(null);
const { data } = usersQuery;
const users = useMemo(() => data ?? [], [data]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return users;
return users.filter(
(u) =>
u.username.toLowerCase().includes(q) ||
(u.email?.toLowerCase().includes(q) ?? false) ||
u.role.toLowerCase().includes(q),
);
}, [users, filter]);
const adminCount = useMemo(
() => users.filter((u) => u.role === "admin").length,
[users],
);
const columns: Column<UserAdmin>[] = [
{
key: "status",
header: "",
cellClass: "dt__status",
render: (u) => (
<StatusDot
tone={userStatusTone(u.enabled)}
label={userStatusLabel(u.enabled)}
/>
),
},
{
key: "username",
header: "User",
render: (u) => (
<span className="userrow__id">
<span className="dt__strong">
{u.username}
{currentUser?.id === u.id && (
<span className="userrow__you">You</span>
)}
</span>
{u.email && <span className="userrow__email">{u.email}</span>}
</span>
),
},
{
key: "role",
header: "Role",
render: (u) => <Badge tone={roleTone(u.role)}>{roleLabel(u.role)}</Badge>,
},
{
key: "permissions",
header: "Permissions",
render: (u) => (
<span className="userrow__perms" title={permissionsSummary(u)}>
{permissionsSummary(u)}
</span>
),
},
{
key: "status_label",
header: "Status",
render: (u) => (
<Badge tone={userStatusTone(u.enabled)} dot>
{userStatusLabel(u.enabled)}
</Badge>
),
},
{
key: "created_at",
header: "Created",
render: (u) => (
<span className="dt__mono" title={absoluteTime(u.created_at)}>
{relativeTime(u.created_at)}
</span>
),
},
{
key: "last_login",
header: "Last login",
render: (u) => (
<span
className="dt__mono"
title={u.last_login ? absoluteTime(u.last_login) : "Never signed in"}
>
{u.last_login ? relativeTime(u.last_login) : "never"}
</span>
),
},
{
key: "actions",
header: "",
cellClass: "dt__actions",
render: (u) => {
const isSelf = currentUser?.id === u.id;
return (
<span
className="dt__rowactions"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={() => setEditFor(u)}
aria-label={`Edit ${u.username}`}
>
<EditIcon width={14} height={14} />
Edit
</Button>
{/* Self-delete is blocked client- and server-side; hide it on the
signed-in admin's own row so it is never offered. */}
{!isSelf && (
<Button
variant="danger"
size="sm"
onClick={() => setDeleteFor(u)}
aria-label={`Delete ${u.username}`}
>
<TrashIcon width={14} height={14} />
Delete
</Button>
)}
</span>
);
},
},
];
return (
<div className="page">
<PageHeader
title="Users"
subtitle="Operator accounts, roles, and permissions for the GuruConnect console."
actions={
<>
<Button
variant="ghost"
onClick={() => void usersQuery.refetch()}
loading={usersQuery.isFetching}
>
<RefreshIcon width={15} height={15} />
Refresh
</Button>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
<PlusIcon width={15} height={15} />
Add user
</Button>
</>
}
/>
<Panel flush>
<div className="userstoolbar">
<div className="toolbar">
<div className="searchbox">
<span className="searchbox__icon">
<SearchIcon width={15} height={15} />
</span>
<Input
placeholder="Filter by username, email, or role"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter users"
/>
</div>
<div className="toolbar__count">
<span className="mono">{adminCount}</span> admin ·{" "}
<span className="mono">{users.length}</span> total
</div>
</div>
</div>
{usersQuery.isLoading ? (
<>
<span className="visually-hidden" role="status">
Loading users
</span>
<TableSkeleton
headers={[
"",
"User",
"Role",
"Permissions",
"Status",
"Created",
"Last login",
"",
]}
/>
</>
) : usersQuery.isError ? (
<ErrorState
title="Could not load users"
message={
usersQuery.error instanceof ApiError
? usersQuery.error.message
: "The GuruConnect server did not respond. Check the relay status, then retry."
}
action={
<Button
variant="primary"
onClick={() => void usersQuery.refetch()}
>
Retry
</Button>
}
/>
) : filtered.length === 0 ? (
filter ? (
<EmptyState
title="No matching users"
message={`Nothing matches "${filter}". Clear the filter to see every user.`}
action={
<Button variant="ghost" onClick={() => setFilter("")}>
Clear filter
</Button>
}
/>
) : (
<EmptyState
title="No users yet"
message="Add an operator account to give a technician access to the console."
action={
<Button variant="primary" onClick={() => setCreateOpen(true)}>
Add user
</Button>
}
/>
)
) : (
<Table
columns={columns}
rows={filtered}
rowKey={(u) => u.id}
onRowClick={setEditFor}
rowLabel={(u) => u.username}
/>
)}
</Panel>
<CreateUserModal
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
<EditUserModal
user={editFor}
isSelf={editFor != null && currentUser?.id === editFor.id}
onClose={() => setEditFor(null)}
/>
<DeleteUserDialog user={deleteFor} onClose={() => setDeleteFor(null)} />
</div>
);
}