feat(dashboard): GuruConnect v2 Users admin view
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

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:
2026-05-30 14:18:40 -07:00
parent 664f33d5ab
commit 96b4fd7721
17 changed files with 1827 additions and 3 deletions

View File

@@ -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() {
<Route path="/machines" element={<MachinesPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/codes" element={<SupportCodesPage />} />
{/* Users lands in a later pass. */}
{/* Users is admin-only: AdminRoute renders an access-denied
panel for non-admins instead of the view. */}
<Route element={<AdminRoute />}>
<Route path="/users" element={<UsersPage />} />
</Route>
<Route path="/" element={<Navigate to="/machines" replace />} />
</Route>
</Route>

View File

@@ -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";

View File

@@ -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<Role, Permission[]> = {
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;

View File

@@ -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<UserAdmin[]> {
return http.get<UserAdmin[]>("/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<UserAdmin> {
return http.post<UserAdmin>("/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<UserAdmin> {
return http.put<UserAdmin>(`/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<void> {
return http.put<void>(`/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<void> {
return http.del<void>(`/api/users/${encodeURIComponent(id)}`);
}

View File

@@ -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 (
<div className="page">
<div className="denied">
<Panel>
<div className="denied__body">
<span className="denied__badge" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</span>
<h1 className="denied__title">Admins only</h1>
<p className="denied__msg">
User management is restricted to administrators. Your account
does not have admin access. If you need it, ask an administrator
to update your role.
</p>
<Link to="/machines" className="btn btn--primary denied__link">
Back to Machines
</Link>
</div>
</Panel>
</div>
</div>
);
}
return <Outlet />;
}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -0,0 +1,236 @@
import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import {
ROLE_DEFAULT_PERMISSIONS,
ROLES,
} from "../../api/types";
import type { Permission, Role } from "../../api/types";
import { Button } from "../../components/ui/Button";
import { Field, Input } from "../../components/ui/Input";
import { Modal } from "../../components/ui/Modal";
import { useToast } from "../../components/ui/toast-context";
import { roleLabel } from "../../components/ui/status";
import { useCreateUser } from "./hooks";
import { PasswordField } from "./PasswordField";
import { PermissionsField } from "./PermissionsField";
import { scorePassword } from "./password";
interface CreateUserModalProps {
open: boolean;
onClose: () => void;
}
const ADMIN_PERMS_NOTE =
"Admins implicitly hold every permission, so there is nothing to choose here.";
/**
* Create-user form. Collects username, optional email, an initial password
* (typed or generated + copied once), a role from the REAL role set, and a
* permission set seeded from the role's server-side defaults. Client-side
* validation gates the submit, but the server is authoritative.
*
* Credential handling: the typed/generated password lives only in this
* component's state and is wiped whenever the dialog closes (success, cancel,
* or escape). It is never logged. The server never returns it.
*/
export function CreateUserModal({ open, onClose }: CreateUserModalProps) {
const toast = useToast();
const create = useCreateUser();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("operator");
// Permissions track the role's defaults until the admin touches them.
const [permissions, setPermissions] = useState<Permission[]>(
ROLE_DEFAULT_PERMISSIONS.operator,
);
const [permsTouched, setPermsTouched] = useState(false);
const [submitted, setSubmitted] = useState(false);
// Reset everything (including the password) every time the dialog opens or
// closes so no credential lingers in state across uses.
useEffect(() => {
setUsername("");
setEmail("");
setPassword("");
setRole("operator");
setPermissions(ROLE_DEFAULT_PERMISSIONS.operator);
setPermsTouched(false);
setSubmitted(false);
create.reset();
// create is stable; resetting on open/close only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
function handleRoleChange(next: Role) {
setRole(next);
// Until the admin explicitly edits permissions, mirror the new role's
// defaults so the preview matches what the server would seed.
if (!permsTouched) setPermissions(ROLE_DEFAULT_PERMISSIONS[next]);
}
function handlePermsChange(next: Permission[]) {
setPermsTouched(true);
setPermissions(next);
}
const usernameError =
submitted && username.trim().length === 0
? "A username is required."
: undefined;
const passwordStrength = scorePassword(password);
const passwordError =
submitted && !passwordStrength.meetsMinimum
? password.length === 0
? "An initial password is required."
: "Password must be at least 8 characters."
: undefined;
const canSubmit =
username.trim().length > 0 && passwordStrength.meetsMinimum;
function handleSubmit() {
setSubmitted(true);
if (username.trim().length === 0 || !passwordStrength.meetsMinimum) return;
// For admin, permissions are implicit server-side — don't send a redundant
// set. For other roles, send the explicit set only when the admin edited
// it; otherwise let the server seed its own role defaults.
const sendPermissions =
role !== "admin" && permsTouched ? permissions : undefined;
create.mutate(
{
username: username.trim(),
password,
email: email.trim() ? email.trim() : null,
role,
permissions: sendPermissions,
},
{
onSuccess: (user) => {
toast.success(
"User created",
`${user.username} can sign in with the password you set.`,
);
// Wipe the password from state immediately on success.
setPassword("");
onClose();
},
onError: (err) => {
toast.error(
"Could not create user",
err instanceof ApiError ? err.message : "Unexpected error.",
);
},
},
);
}
if (!open) return null;
const roleSelectId = "create-user-role";
return (
<Modal
open={open}
title="Add user"
ariaLabel="Add user"
onClose={create.isPending ? () => {} : onClose}
dismissable={!create.isPending}
wide
footer={
<>
<Button
variant="ghost"
onClick={onClose}
disabled={create.isPending}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
loading={create.isPending}
disabled={!canSubmit && submitted}
>
Create user
</Button>
</>
}
>
<div className="userform">
<Field label="Username" htmlFor="create-user-username">
<Input
id="create-user-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={create.isPending}
autoComplete="off"
spellCheck={false}
aria-invalid={usernameError ? true : undefined}
aria-describedby={
usernameError ? "create-user-username-err" : undefined
}
/>
{usernameError && (
<p
className="userform__err"
id="create-user-username-err"
role="alert"
>
{usernameError}
</p>
)}
</Field>
<Field label="Email (optional)" htmlFor="create-user-email">
<Input
id="create-user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={create.isPending}
autoComplete="off"
spellCheck={false}
/>
</Field>
<PasswordField
label="Initial password"
value={password}
onChange={setPassword}
disabled={create.isPending}
error={passwordError}
/>
<div className="field">
<label className="field__label" htmlFor={roleSelectId}>
Role
</label>
<select
id={roleSelectId}
className="select"
value={role}
disabled={create.isPending}
onChange={(e) => handleRoleChange(e.target.value as Role)}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{roleLabel(r)}
</option>
))}
</select>
</div>
<PermissionsField
value={permissions}
onChange={handlePermsChange}
disabled={create.isPending}
lockedNote={role === "admin" ? ADMIN_PERMS_NOTE : undefined}
/>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,61 @@
import { ApiError } from "../../api/client";
import type { UserAdmin } from "../../api/types";
import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
import { useToast } from "../../components/ui/toast-context";
import { useDeleteUser } from "./hooks";
interface DeleteUserDialogProps {
/** The user pending deletion, or null when closed. */
user: UserAdmin | null;
onClose: () => void;
}
/**
* Permanent user deletion behind the shared ConfirmDialog (a consequential,
* irreversible action). The caller never opens this for the signed-in admin —
* the page hides the Delete action on the self row and the server independently
* rejects a self-delete — so this dialog only ever targets another user.
*/
export function DeleteUserDialog({ user, onClose }: DeleteUserDialogProps) {
const toast = useToast();
const del = useDeleteUser();
function handleConfirm() {
if (!user) return;
del.mutate(user.id, {
onSuccess: () => {
toast.success("User deleted", `${user.username} can no longer sign in.`);
onClose();
},
onError: (err) => {
toast.error(
"Could not delete user",
err instanceof ApiError
? err.message
: "The server did not respond. The user was not deleted.",
);
},
});
}
return (
<ConfirmDialog
open={user != null}
title="Delete user"
danger
busy={del.isPending}
confirmLabel="Delete user"
cancelLabel="Keep user"
body={
<span>
Permanently delete{" "}
<span className="mono">{user?.username}</span> and revoke their access
to GuruConnect. This cannot be undone. To temporarily block sign-in
without losing the account, disable it instead.
</span>
}
onConfirm={handleConfirm}
onCancel={onClose}
/>
);
}

View File

@@ -0,0 +1,280 @@
import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import {
PERMISSIONS,
ROLE_DEFAULT_PERMISSIONS,
ROLES,
} from "../../api/types";
import type { Permission, Role, UserAdmin } from "../../api/types";
import { Button } from "../../components/ui/Button";
import { Field, Input } from "../../components/ui/Input";
import { Modal } from "../../components/ui/Modal";
import { useToast } from "../../components/ui/toast-context";
import { roleLabel } from "../../components/ui/status";
import { useUpdateUser } from "./hooks";
import { PasswordField } from "./PasswordField";
import { PermissionsField } from "./PermissionsField";
import { scorePassword } from "./password";
interface EditUserModalProps {
/** The user being edited, or null when the dialog is closed. */
user: UserAdmin | null;
/** True when `user` is the signed-in admin (drives self-lockout guards). */
isSelf: boolean;
onClose: () => void;
}
const ADMIN_PERMS_NOTE =
"Admins implicitly hold every permission, so there is nothing to choose here.";
function isRole(value: string): value is Role {
return (ROLES as readonly string[]).includes(value);
}
/**
* Edit-user form: change role, permissions, and active status, and optionally
* set a new password (blank = leave unchanged — the password is never returned,
* so it cannot be pre-filled and an empty field means "no change").
*
* Self-lockout guards (defense-in-depth + UX): when editing your own account
* the "Disabled" control is locked (you cannot disable yourself) and you cannot
* demote yourself out of `admin`. The server independently rejects a self-
* disable; it does NOT currently block a self-role-demotion, so this client
* guard is the only thing preventing an admin from accidentally locking
* themselves out of the admin plane.
*
* Credential handling: the new-password value lives only in component state and
* is wiped on every open/close; it is never logged.
*/
export function EditUserModal({ user, isSelf, onClose }: EditUserModalProps) {
const toast = useToast();
const update = useUpdateUser();
const [email, setEmail] = useState("");
const [role, setRole] = useState<Role>("operator");
const [enabled, setEnabled] = useState(true);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [permsTouched, setPermsTouched] = useState(false);
const [newPassword, setNewPassword] = useState("");
const [submitted, setSubmitted] = useState(false);
// Seed the form from the target user whenever the dialog opens for one.
useEffect(() => {
if (!user) return;
setEmail(user.email ?? "");
setRole(isRole(user.role) ? user.role : "operator");
setEnabled(user.enabled);
// Keep only strings that are real, server-recognized permissions; drop any
// legacy/unknown value so the editor never re-submits something invalid.
setPermissions(
user.permissions.filter((p): p is Permission =>
(PERMISSIONS as readonly string[]).includes(p),
),
);
setPermsTouched(false);
setNewPassword("");
setSubmitted(false);
update.reset();
// update is stable; reseed only when the target user changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
function handleRoleChange(next: Role) {
setRole(next);
if (!permsTouched) setPermissions(ROLE_DEFAULT_PERMISSIONS[next]);
}
function handlePermsChange(next: Permission[]) {
setPermsTouched(true);
setPermissions(next);
}
// Self-lockout: an admin editing their own account may not demote out of
// admin (would strip their own management access). Lock the non-admin options.
const lockSelfDemotion = isSelf && user?.role === "admin";
const passwordStrength = scorePassword(newPassword);
// Password is optional on edit; only validate when the admin actually typed one.
const passwordError =
submitted && newPassword.length > 0 && !passwordStrength.meetsMinimum
? "Password must be at least 8 characters."
: undefined;
const canSubmit =
newPassword.length === 0 || passwordStrength.meetsMinimum;
function handleSubmit() {
if (!user) return;
setSubmitted(true);
if (newPassword.length > 0 && !passwordStrength.meetsMinimum) return;
// Belt-and-suspenders self-guards (the controls are already locked, but
// never trust the rendered state for a security-relevant action).
if (isSelf && !enabled) {
toast.error("Cannot disable your own account");
return;
}
if (lockSelfDemotion && role !== "admin") {
toast.error("Cannot remove your own admin role");
return;
}
const sendPermissions =
role !== "admin" && permsTouched ? permissions : undefined;
update.mutate(
{
id: user.id,
body: {
email: email.trim() ? email.trim() : null,
role,
enabled,
password: newPassword.length > 0 ? newPassword : undefined,
},
permissions: sendPermissions,
},
{
onSuccess: () => {
toast.success(
"User updated",
newPassword.length > 0
? `${user.username}'s password was reset.`
: undefined,
);
setNewPassword("");
onClose();
},
onError: (err) => {
// ApiError carries a server message; the hook also rejects with a
// plain Error on partial failure ("User saved, but permissions
// update failed ...") — surface that text rather than swallowing it.
const detail =
err instanceof ApiError || err instanceof Error
? err.message
: "Unexpected error.";
toast.error("Could not update user", detail);
},
},
);
}
if (!user) return null;
const roleSelectId = "edit-user-role";
return (
<Modal
open={user != null}
title={
<>
Edit user ·{" "}
<span className="mono" style={{ fontWeight: 500 }}>
{user.username}
</span>
</>
}
ariaLabel={`Edit user ${user.username}`}
onClose={update.isPending ? () => {} : onClose}
dismissable={!update.isPending}
wide
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={update.isPending}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
loading={update.isPending}
disabled={!canSubmit && submitted}
>
Save changes
</Button>
</>
}
>
<div className="userform">
<Field label="Email (optional)" htmlFor="edit-user-email">
<Input
id="edit-user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={update.isPending}
autoComplete="off"
spellCheck={false}
/>
</Field>
<div className="field">
<label className="field__label" htmlFor={roleSelectId}>
Role
</label>
<select
id={roleSelectId}
className="select"
value={role}
disabled={update.isPending || lockSelfDemotion}
onChange={(e) => handleRoleChange(e.target.value as Role)}
aria-describedby={
lockSelfDemotion ? "edit-user-role-lock" : undefined
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{roleLabel(r)}
</option>
))}
</select>
{lockSelfDemotion && (
<p className="userform__note" id="edit-user-role-lock">
You cannot remove your own admin role.
</p>
)}
</div>
<PermissionsField
value={permissions}
onChange={handlePermsChange}
disabled={update.isPending}
lockedNote={role === "admin" ? ADMIN_PERMS_NOTE : undefined}
/>
<div className="field">
<span className="field__label">Account status</span>
<label className="optline">
<input
type="checkbox"
checked={!enabled}
disabled={update.isPending || isSelf}
onChange={(e) => setEnabled(!e.target.checked)}
/>
<span>
Disable this account
{isSelf ? (
<em className="optline__note">
{" "}
(you cannot disable your own account)
</em>
) : (
<em className="userform__hint">
{" "}
(a disabled user cannot sign in)
</em>
)}
</span>
</label>
</div>
<PasswordField
label="Reset password (optional)"
value={newPassword}
onChange={setNewPassword}
disabled={update.isPending}
error={passwordError}
emptyHint="Leave blank to keep the current password."
/>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,132 @@
import { useId, useState } from "react";
import { Button } from "../../components/ui/Button";
import { Input } from "../../components/ui/Input";
import {
EyeIcon,
EyeOffIcon,
ShuffleIcon,
} from "../../components/layout/icons";
import { useClipboard } from "../../lib/useClipboard";
import { generatePassword, scorePassword } from "./password";
interface PasswordFieldProps {
label: string;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
/** Surface a server-side or validation error under the field. */
error?: string;
/**
* Hint shown when the field is empty (e.g. "Leave blank to keep current" on
* the edit form). Suppressed once the admin types.
*/
emptyHint?: string;
}
/**
* Password input with hygiene baked in:
* - type="password" with autoComplete="new-password" (never autofills or
* suggests the admin's own saved credentials),
* - a reveal toggle (off by default) so a generated value can be verified,
* - a "generate" action that fills a strong crypto-random value and copies it
* once, mirroring the cak_ key reveal discipline (copy now, it is not stored
* anywhere after the form closes),
* - an advisory strength meter (the server is the real policy authority).
*
* The value lives only in the parent's component state and is cleared on modal
* close by the parent; it is never logged.
*/
export function PasswordField({
label,
value,
onChange,
disabled = false,
error,
emptyHint,
}: PasswordFieldProps) {
const id = useId();
const hintId = useId();
const [revealed, setRevealed] = useState(false);
const { copied, copy } = useClipboard();
const strength = scorePassword(value);
function handleGenerate() {
const next = generatePassword(20);
onChange(next);
setRevealed(true);
void copy(next);
}
const describedBy = error
? hintId
: value || emptyHint
? hintId
: undefined;
return (
<div className="field">
<label className="field__label" htmlFor={id}>
{label}
</label>
<div className="pwfield">
<Input
id={id}
type={revealed ? "text" : "password"}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
autoComplete="new-password"
spellCheck={false}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className="pwfield__input"
/>
<div className="pwfield__actions">
<button
type="button"
className="iconbtn"
onClick={() => setRevealed((r) => !r)}
disabled={disabled}
aria-label={revealed ? "Hide password" : "Show password"}
aria-pressed={revealed}
>
{revealed ? (
<EyeOffIcon width={16} height={16} />
) : (
<EyeIcon width={16} height={16} />
)}
</button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleGenerate}
disabled={disabled}
>
<ShuffleIcon width={14} height={14} />
{copied ? "Copied" : "Generate"}
</Button>
</div>
</div>
{error ? (
<p className="pwfield__msg pwfield__msg--error" id={hintId} role="alert">
{error}
</p>
) : value ? (
<div className="pwfield__strength" id={hintId}>
<span
className={`pwfield__bar pwfield__bar--${strength.level}`}
aria-hidden="true"
/>
<span className="pwfield__strengthtext">{strength.hint}</span>
</div>
) : emptyHint ? (
<p className="pwfield__msg" id={hintId}>
{emptyHint}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useId } from "react";
import { PERMISSIONS } from "../../api/types";
import type { Permission } from "../../api/types";
/** Short human gloss for each permission so the checkbox list is self-explaining. */
const PERMISSION_LABEL: Record<Permission, string> = {
view: "View sessions",
control: "Take remote control",
transfer: "Transfer files",
manage_users: "Manage users",
manage_clients: "Manage clients",
};
interface PermissionsFieldProps {
/** Currently selected permission strings. */
value: (Permission | string)[];
onChange: (next: Permission[]) => void;
/** Disable the whole group (e.g. while a save is in flight, or for admins). */
disabled?: boolean;
/**
* When set, the group is read-only and shows this explanation instead of
* being editable. Used for the `admin` role, whose permissions are implicit
* (admins hold every permission server-side regardless of the stored set).
*/
lockedNote?: string;
}
/**
* Permission multiselect built from the REAL server permission catalog
* (`PERMISSIONS`). The strings here are the exact ones the rest of the app
* checks — no invented values. Renders as a labeled checkbox group.
*/
export function PermissionsField({
value,
onChange,
disabled = false,
lockedNote,
}: PermissionsFieldProps) {
const groupId = useId();
const selected = new Set(value);
function toggle(perm: Permission, checked: boolean) {
const next = new Set(selected);
if (checked) next.add(perm);
else next.delete(perm);
// Preserve the canonical catalog order so the stored set is stable.
onChange(PERMISSIONS.filter((p) => next.has(p)));
}
return (
<fieldset className="permset" aria-describedby={lockedNote ? groupId : undefined}>
<legend className="field__label">Permissions</legend>
{lockedNote ? (
<p className="permset__locked" id={groupId}>
{lockedNote}
</p>
) : (
<div className="permset__grid">
{PERMISSIONS.map((perm) => (
<label key={perm} className="permset__opt">
<input
type="checkbox"
checked={selected.has(perm)}
disabled={disabled}
onChange={(e) => toggle(perm, e.target.checked)}
/>
<span className="permset__opttext">
<span className="permset__optname">
{PERMISSION_LABEL[perm]}
</span>
<code className="permset__optcode mono">{perm}</code>
</span>
</label>
))}
</div>
)}
</fieldset>
);
}

View File

@@ -0,0 +1,303 @@
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>
);
}

View File

@@ -0,0 +1,97 @@
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import * as usersApi from "../../api/users";
import type {
CreateUserRequest,
Permission,
UpdateUserRequest,
} from "../../api/types";
// Distinct from ["machines"], ["sessions"], ["codes"], ["machine-keys"] — no
// cache-key collision with the other features.
const USERS_KEY = ["users"] as const;
/**
* List all users. Users change rarely (an admin creates/edits them by hand), so
* there is no polling — a modest staleTime plus explicit invalidation on every
* mutation keeps the table correct without a render storm. AbortSignal is
* threaded so a navigation-away cancels the in-flight request.
*/
export function useUsers() {
return useQuery({
queryKey: USERS_KEY,
queryFn: ({ signal }) => usersApi.listUsers(signal),
staleTime: 30_000,
});
}
/** Create a user, then invalidate the list so the new row appears. */
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateUserRequest) => usersApi.createUser(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
}
/**
* Update a user's core fields (role / enabled / email / optional password) and,
* when `permissions` is provided, replace their permission set in the same
* logical save. The two calls are sequenced (user first, then permissions) so a
* failure on either surfaces as a single mutation error.
*
* This is a two-step write, so the steps can succeed independently. If the core
* update lands but the permissions replacement throws, the account fields are
* already persisted server-side while the mutation still rejects — so the error
* message is rewritten to make clear the account saved and only permissions need
* a retry. Either way, the list is invalidated in `onSettled` (not `onSuccess`)
* so the table reconciles to true server state after a partial failure instead
* of showing the stale pre-edit row behind an error toast.
*/
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (vars: {
id: string;
body: UpdateUserRequest;
permissions?: (Permission | string)[];
}) => {
const updated = await usersApi.updateUser(vars.id, vars.body);
if (vars.permissions) {
try {
await usersApi.setUserPermissions(vars.id, vars.permissions);
} catch (err) {
// The core update already succeeded; signal a partial failure so the
// admin knows to retry permissions specifically rather than re-saving
// the whole form.
throw new Error(
"User saved, but permissions update failed - retry permissions.",
{ cause: err },
);
}
}
return updated;
},
// Reconcile the table after EITHER outcome: on a partial failure the core
// fields are persisted, so the cached row would otherwise stay stale.
onSettled: () => {
void qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
}
/** Delete a user, then invalidate the list so the row disappears. */
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => usersApi.deleteUser(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
}

View File

@@ -0,0 +1,82 @@
// Client-side password helpers for the Users admin flows.
//
// The server is authoritative on password policy (it enforces a >= 8 char
// minimum). These helpers are purely for UX: a generator for "make me a strong
// one" and a lightweight strength hint shown next to the field. Neither value
// is ever logged or persisted beyond the live form state.
// Mirrors the server's generator charset (server/src/auth/password.rs) — an
// unambiguous alphanumeric set plus a few symbols, with confusable characters
// (0/O, 1/l/I) already excluded so a generated password is safe to read aloud.
const CHARSET =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
/**
* Generate a cryptographically-random password of `length` chars using the
* Web Crypto API (never Math.random). Rejection-samples so every character is
* uniformly distributed across the charset with no modulo bias.
*/
export function generatePassword(length = 20): string {
const max = Math.floor(256 / CHARSET.length) * CHARSET.length;
const out: string[] = [];
const buf = new Uint8Array(1);
while (out.length < length) {
crypto.getRandomValues(buf);
const byte = buf[0];
if (byte < max) out.push(CHARSET[byte % CHARSET.length]);
}
return out.join("");
}
export type StrengthLevel = "weak" | "fair" | "strong";
export interface PasswordStrength {
level: StrengthLevel;
/** Human hint shown under the field. */
hint: string;
/** True once the server's hard minimum (8 chars) is met. */
meetsMinimum: boolean;
}
/**
* A pragmatic strength hint (NOT a security gate — the server enforces policy).
* Scores length and character-class variety; the bar is intentionally simple
* and advisory so an admin understands when a password is thin.
*/
export function scorePassword(password: string): PasswordStrength {
const len = password.length;
const meetsMinimum = len >= 8;
if (len === 0) {
return { level: "weak", hint: "", meetsMinimum: false };
}
if (!meetsMinimum) {
return {
level: "weak",
hint: "Too short — at least 8 characters required.",
meetsMinimum: false,
};
}
let classes = 0;
if (/[a-z]/.test(password)) classes++;
if (/[A-Z]/.test(password)) classes++;
if (/[0-9]/.test(password)) classes++;
if (/[^A-Za-z0-9]/.test(password)) classes++;
if (len >= 14 && classes >= 3) {
return { level: "strong", hint: "Strong password.", meetsMinimum: true };
}
if (len >= 10 && classes >= 2) {
return {
level: "fair",
hint: "Fair — longer with more character variety is stronger.",
meetsMinimum: true,
};
}
return {
level: "fair",
hint: "Acceptable, but a longer, more varied password is recommended.",
meetsMinimum: true,
};
}

View File

@@ -0,0 +1,256 @@
/* ===================================================== Users feature styles */
/* ---------------------------------------------------------------- Table cells */
/* User identity cell: username (with a "You" tag on the self row) over email. */
.userrow__id {
display: flex;
flex-direction: column;
gap: 2px;
}
.userrow__you {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-soft);
vertical-align: middle;
}
.userrow__email {
font-size: 12px;
color: var(--text-muted);
}
.userrow__perms {
display: inline-block;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text-muted);
}
/* Toolbar inset: pads the filter/count row off the panel edge (the table below
is flush, so only the toolbar needs the inset). */
.userstoolbar {
padding: 14px 16px 0;
}
/* ----------------------------------------------------------------- User form */
.userform {
display: flex;
flex-direction: column;
gap: 18px;
}
.userform__err {
margin: 0;
font-size: 12px;
color: var(--bad);
}
.userform__note {
margin: 0;
font-size: 12px;
color: var(--warn);
}
.userform__hint {
color: var(--text-muted);
font-style: normal;
}
/* Native select styled to match the Input primitive. */
.select {
height: 36px;
padding: 0 36px 0 12px;
background: var(--panel-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
font-family: inherit;
appearance: none;
cursor: pointer;
/* Brand-tinted chevron, drawn as an inline SVG so it follows the token hue. */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%237b8a8e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
transition:
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* --------------------------------------------------------- Permissions group */
.permset {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.permset > .field__label {
padding: 0;
}
.permset__locked {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.permset__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
}
.permset__opt {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--panel-2);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease);
}
.permset__opt:hover {
border-color: var(--border-strong);
}
.permset__opt input[type="checkbox"] {
margin-top: 2px;
width: 15px;
height: 15px;
accent-color: var(--accent);
flex: 0 0 auto;
}
.permset__opt input[type="checkbox"]:disabled {
cursor: not-allowed;
}
.permset__opttext {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.permset__optname {
font-size: 13px;
color: var(--text);
}
.permset__optcode {
font-size: 11px;
color: var(--text-faint);
}
/* ---------------------------------------------------------- Password control */
.pwfield {
display: flex;
align-items: center;
gap: 8px;
}
.pwfield__input {
flex: 1;
min-width: 0;
}
.pwfield__actions {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.pwfield__msg {
margin: 0;
font-size: 12px;
color: var(--text-muted);
}
.pwfield__msg--error {
color: var(--bad);
}
.pwfield__strength {
display: flex;
align-items: center;
gap: 10px;
}
.pwfield__bar {
position: relative;
flex: 0 0 64px;
height: 4px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.pwfield__bar::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
transform-origin: left;
}
.pwfield__bar--weak::after {
width: 33%;
background: var(--bad);
}
.pwfield__bar--fair::after {
width: 66%;
background: var(--warn);
}
.pwfield__bar--strong::after {
width: 100%;
background: var(--ok);
}
.pwfield__strengthtext {
font-size: 12px;
color: var(--text-muted);
}
/* ------------------------------------------------------------ Access denied */
.denied {
display: grid;
place-items: center;
padding: 48px 0;
}
.denied__body {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
max-width: 440px;
padding: 16px 8px;
}
.denied__badge {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 999px;
color: var(--warn);
background: var(--warn-soft);
}
.denied__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.denied__msg {
margin: 0;
font-size: 14px;
color: var(--text-muted);
line-height: 1.5;
}
.denied__link {
margin-top: 4px;
text-decoration: none;
}