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>
304 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|