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 { 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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
dashboard/src/api/users.ts
Normal file
58
dashboard/src/api/users.ts
Normal 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)}`);
|
||||
}
|
||||
56
dashboard/src/auth/AdminRoute.tsx
Normal file
56
dashboard/src/auth/AdminRoute.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
236
dashboard/src/features/users/CreateUserModal.tsx
Normal file
236
dashboard/src/features/users/CreateUserModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
dashboard/src/features/users/DeleteUserDialog.tsx
Normal file
61
dashboard/src/features/users/DeleteUserDialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
280
dashboard/src/features/users/EditUserModal.tsx
Normal file
280
dashboard/src/features/users/EditUserModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
dashboard/src/features/users/PasswordField.tsx
Normal file
132
dashboard/src/features/users/PasswordField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
dashboard/src/features/users/PermissionsField.tsx
Normal file
79
dashboard/src/features/users/PermissionsField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
dashboard/src/features/users/UsersPage.tsx
Normal file
303
dashboard/src/features/users/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
dashboard/src/features/users/hooks.ts
Normal file
97
dashboard/src/features/users/hooks.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
82
dashboard/src/features/users/password.ts
Normal file
82
dashboard/src/features/users/password.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
256
dashboard/src/features/users/users.css
Normal file
256
dashboard/src/features/users/users.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user