Files
sencho/frontend/src/components/settings/UsersSection.tsx
T
Anso eead195529 feat(settings): dress the page to match the audit (#849)
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)

Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.

Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.

Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.

Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.

* refactor(settings): drop react-router-dom and align with DESIGN.md

The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:

- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
  inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
  useNavigate. SettingsPage owns currentSection via props lifted to
  EditorLayout, so cross-component navigation (openLabelManager,
  onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
  redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json

The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:

- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
  11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
  font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
  --success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
  tone now genuinely uses --success green; new brand tone for promotional
  callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
  size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
  filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
  aside gains text-card-foreground transition-colors per §10 canonical
  card class
- SettingsPage main panel: text-card-foreground transition-colors added;
  uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
  tokens with literal fallbacks so Settings respects the comfortable/
  compact toggle

* fix(e2e): update mfa openAccountSettings to match settings redesign

Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.

* test(e2e): fix MFA enrolment assertion after settings redesign

The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.

* test(e2e): fix low-backup-codes warning assertions after settings redesign

Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
  identifies the zero-codes error card without hitting strict-mode
  from two identically-labelled Regenerate buttons on the page

* test(e2e): navigate to root before re-opening settings for mock refresh

The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.

* test(e2e): scroll zero-codes callout into view before asserting visibility

The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.

* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion

The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.

* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue

The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
2026-04-30 19:37:38 -04:00

511 lines
27 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
interface UserItem {
id: number;
username: string;
role: UserRole;
auth_provider: string;
created_at: number;
mfaEnabled?: boolean;
}
interface RoleAssignmentItem {
id: number;
user_id: number;
role: UserRole;
resource_type: 'stack' | 'node';
resource_id: string;
created_at: number;
}
export function UsersSection() {
const { user: currentUser } = useAuth();
const { isPaid, license } = useLicense();
const [users, setUsers] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const [saving, setSaving] = useState(false);
// Form state
const [formUsername, setFormUsername] = useState('');
const [formPassword, setFormPassword] = useState('');
const [formConfirmPassword, setFormConfirmPassword] = useState('');
const [formRole, setFormRole] = useState<UserRole>('viewer');
const fetchUsers = async () => {
try {
const res = await apiFetch('/users', { localOnly: true });
if (res.ok) setUsers(await res.json());
} catch { /* ignore */ } finally { setLoading(false); }
};
useEffect(() => { fetchUsers(); }, []);
useMastheadStats(
loading
? null
: [
{ label: 'OPERATORS', value: `${users.length}` },
],
);
const resetForm = () => {
setFormUsername('');
setFormPassword('');
setFormConfirmPassword('');
setFormRole('viewer');
setEditingUser(null);
setShowForm(false);
};
const handleSave = async () => {
if (!formUsername || formUsername.length < 3) {
toast.error('Username must be at least 3 characters.');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(formUsername)) {
toast.error('Username can only contain letters, numbers, underscores, and hyphens.');
return;
}
if (!editingUser && !formPassword) {
toast.error('Password is required for new users.');
return;
}
if (formPassword && formPassword.length < 8) {
toast.error('Password must be at least 8 characters.');
return;
}
if (formPassword && formPassword !== formConfirmPassword) {
toast.error('Passwords do not match.');
return;
}
setSaving(true);
try {
if (editingUser) {
const body: Record<string, string> = { username: formUsername, role: formRole };
if (formPassword) body.password = formPassword;
const res = await apiFetch(`/users/${editingUser.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
localOnly: true,
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to update user.');
return;
}
toast.success('User updated.');
} else {
const res = await apiFetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: formUsername, password: formPassword, role: formRole }),
localOnly: true,
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to create user.');
return;
}
toast.success('User created.');
}
resetForm();
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally {
setSaving(false);
}
};
const handleResetMfa = async (userId: number, username: string) => {
try {
const res = await apiFetch(`/users/${userId}/mfa/reset`, { method: 'POST', localOnly: true });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to reset two-factor authentication.');
return;
}
toast.success(`Two-factor authentication reset for ${username}.`);
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
const handleDelete = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true });
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to delete user.');
return;
}
toast.success('User deleted.');
fetchUsers();
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
const startEdit = (u: UserItem) => {
setEditingUser(u);
setFormUsername(u.username);
setFormRole(u.role);
setFormPassword('');
setFormConfirmPassword('');
setShowForm(true);
fetchRoleAssignments(u.id);
fetchScopeResources();
};
// --- Scoped Role Assignments ---
const [roleAssignments, setRoleAssignments] = useState<RoleAssignmentItem[]>([]);
const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack');
const [scopeResourceId, setScopeResourceId] = useState('');
const [scopeRole, setScopeRole] = useState<UserRole>('deployer');
const [availableStacks, setAvailableStacks] = useState<string[]>([]);
const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]);
const [addingScope, setAddingScope] = useState(false);
const fetchRoleAssignments = async (userId: number) => {
try {
const res = await apiFetch(`/users/${userId}/roles`, { localOnly: true });
if (res.ok) setRoleAssignments(await res.json());
else setRoleAssignments([]);
} catch { setRoleAssignments([]); }
};
const fetchScopeResources = async () => {
try {
const [stacksRes, nodesRes] = await Promise.all([
apiFetch('/stacks', { localOnly: true }),
apiFetch('/nodes', { localOnly: true }),
]);
if (stacksRes.ok) {
const data = await stacksRes.json();
setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []);
}
if (nodesRes.ok) {
const data = await nodesRes.json();
setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []);
}
} catch { /* ignore */ }
};
const addRoleAssignment = async () => {
if (!editingUser || !scopeResourceId) return;
setAddingScope(true);
try {
const res = await apiFetch(`/users/${editingUser.id}/roles`, {
method: 'POST',
localOnly: true,
body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }),
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to add scope.');
return;
}
toast.success('Scope added.');
setScopeResourceId('');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
} finally { setAddingScope(false); }
};
const removeRoleAssignment = async (assignId: number) => {
if (!editingUser) return;
try {
const res = await apiFetch(`/users/${editingUser.id}/roles/${assignId}`, { method: 'DELETE', localOnly: true });
if (!res.ok) {
const err = await res.json();
toast.error(err?.error || err?.message || 'Failed to remove scope.');
return;
}
toast.success('Scope removed.');
fetchRoleAssignments(editingUser.id);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Something went wrong.';
toast.error(msg);
}
};
return (
<PaidGate featureName="User management">
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
{!showForm && (
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4" strokeWidth={1.5} />Add user
</SettingsPrimaryButton>
</div>
)}
{/* Add/Edit Form */}
{showForm && (
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<h4 className="text-sm font-medium">{editingUser ? 'Edit User' : 'New User'}</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Username</Label>
<Input
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
placeholder="username"
/>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Combobox
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'viewer', label: 'Viewer' },
...(isPaid && license?.variant === 'admiral' ? [
{ value: 'deployer', label: 'Deployer' },
{ value: 'node-admin', label: 'Node Admin' },
{ value: 'auditor', label: 'Auditor' },
] : []),
]}
value={formRole}
onValueChange={(v) => setFormRole(v as UserRole)}
placeholder="Select role..."
/>
</div>
</div>
{/* Hide password fields for SSO-provisioned users */}
{(!editingUser || editingUser.auth_provider === 'local') ? (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{editingUser ? 'New Password (optional)' : 'Password'}</Label>
<Input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'}
/>
</div>
<div className="space-y-2">
<Label>Confirm Password</Label>
<Input
type="password"
value={formConfirmPassword}
onChange={(e) => setFormConfirmPassword(e.target.value)}
placeholder="Confirm password"
/>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
Password is managed by the identity provider ({editingUser.auth_provider}).
</p>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />Saving</> : (editingUser ? 'Update user' : 'Create user')}
</SettingsPrimaryButton>
</div>
{/* Scoped Permissions (Admiral, editing only) */}
{editingUser && isPaid && license?.variant === 'admiral' && (
<div className="border border-glass-border rounded-lg p-4 space-y-3 mt-4">
<h4 className="text-sm font-medium">Scoped Permissions</h4>
<p className="text-xs text-muted-foreground">
Grant additional permissions on specific stacks or nodes. These supplement the user's global role.
</p>
{roleAssignments.length > 0 && (
<div className="space-y-1">
{roleAssignments.map((a) => (
<div key={a.id} className="flex items-center justify-between text-sm bg-muted/50 rounded px-3 py-1.5">
<span>
<Badge variant="outline" className="text-xs mr-2 capitalize">{a.role}</Badge>
on <span className="font-medium capitalize">{a.resource_type}</span>: <span className="font-mono text-xs">{a.resource_id}</span>
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeRoleAssignment(a.id)}>
<Trash2 className="w-3 h-3 text-destructive" strokeWidth={1.5} />
</Button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<div className="space-y-1">
<Label className="text-xs">Role</Label>
<Combobox
options={[
{ value: 'deployer', label: 'Deployer' },
{ value: 'node-admin', label: 'Node Admin' },
{ value: 'admin', label: 'Admin' },
]}
value={scopeRole}
onValueChange={(v) => setScopeRole(v as UserRole)}
placeholder="Role..."
className="h-8 text-xs w-[120px]"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Resource Type</Label>
<Combobox
options={[
{ value: 'stack', label: 'Stack' },
{ value: 'node', label: 'Node' },
]}
value={scopeResourceType}
onValueChange={(v) => { setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }}
placeholder="Type..."
className="h-8 text-xs w-[100px]"
/>
</div>
<div className="space-y-1 flex-1">
<Label className="text-xs">Resource</Label>
<Combobox
options={scopeResourceType === 'stack'
? availableStacks.map((s) => ({ value: s, label: s }))
: availableNodes.map((n) => ({ value: String(n.id), label: n.name }))
}
value={scopeResourceId}
onValueChange={setScopeResourceId}
placeholder="Select..."
className="h-8 text-xs"
/>
</div>
<Button size="sm" className="h-8" onClick={addRoleAssignment} disabled={addingScope || !scopeResourceId}>
<Plus className="w-3 h-3 mr-1" strokeWidth={1.5} />
Add
</Button>
</div>
</div>
)}
</div>
)}
{/* Users Table */}
{loading ? (
<div className="space-y-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : users.length === 0 ? (
<SettingsCallout
title="No users yet"
subtitle="Add an operator to give someone else access to this control plane."
/>
) : (
<div className="border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 border-b border-glass-border">
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isSelf = u.username === currentUser?.username;
return (
<tr key={u.id} className="border-b border-glass-border last:border-0 hover:bg-muted/10">
<td className="px-4 py-2.5 font-medium">
{u.username}
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{u.mfaEnabled && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" title="Reset 2FA">
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset two-factor authentication for "{u.username}"?</AlertDialogTitle>
<AlertDialogDescription>
This removes the user's authenticator enrolment and backup codes. They will sign in with just their password on their next login and can re-enrol from their account settings. Use this when a user has lost access to their authenticator.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleResetMfa(u.id, u.username)}>Reset 2FA</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" disabled={isSelf}>
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete user "{u.username}"?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The user will lose access immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(u.id)}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</CapabilityGate>
</PaidGate>
);
}