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([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingUser, setEditingUser] = useState(null); const [saving, setSaving] = useState(false); // Form state const [formUsername, setFormUsername] = useState(''); const [formPassword, setFormPassword] = useState(''); const [formConfirmPassword, setFormConfirmPassword] = useState(''); const [formRole, setFormRole] = useState('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 = { 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([]); const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack'); const [scopeResourceId, setScopeResourceId] = useState(''); const [scopeRole, setScopeRole] = useState('deployer'); const [availableStacks, setAvailableStacks] = useState([]); 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 (
{!showForm && (
{ resetForm(); setShowForm(true); }}> Add user
)} {/* Add/Edit Form */} {showForm && (

{editingUser ? 'Edit User' : 'New User'}

setFormUsername(e.target.value)} placeholder="username" />
setFormRole(v as UserRole)} placeholder="Select role..." />
{/* Hide password fields for SSO-provisioned users */} {(!editingUser || editingUser.auth_provider === 'local') ? (
setFormPassword(e.target.value)} placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'} />
setFormConfirmPassword(e.target.value)} placeholder="Confirm password" />
) : (

Password is managed by the identity provider ({editingUser.auth_provider}).

)}
{saving ? <>Saving : (editingUser ? 'Update user' : 'Create user')}
{/* Scoped Permissions (Admiral, editing only) */} {editingUser && isPaid && license?.variant === 'admiral' && (

Scoped Permissions

Grant additional permissions on specific stacks or nodes. These supplement the user's global role.

{roleAssignments.length > 0 && (
{roleAssignments.map((a) => (
{a.role} on {a.resource_type}: {a.resource_id}
))}
)}
setScopeRole(v as UserRole)} placeholder="Role..." className="h-8 text-xs w-[120px]" />
{ setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }} placeholder="Type..." className="h-8 text-xs w-[100px]" />
({ 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" />
)}
)} {/* Users Table */} {loading ? (
) : users.length === 0 ? ( ) : (
{users.map((u) => { const isSelf = u.username === currentUser?.username; return ( ); })}
Username Role Created Actions
{u.username} {isSelf && (you)} {u.role} {new Date(u.created_at).toLocaleDateString()}
{u.mfaEnabled && ( Reset two-factor authentication for "{u.username}"? 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. Cancel handleResetMfa(u.id, u.username)}>Reset 2FA )} Delete user "{u.username}"? This action cannot be undone. The user will lose access immediately. Cancel handleDelete(u.id)}>Delete
)}
); }