import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Crown, Trash2, Loader2, KeyRound, SquareTerminal as TerminalIcon } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { CreateUserForm } from './CreateUserForm'; type ManagedUser = { id: number; email: string; name: string | null; username: string | null; status: string; role: string; createdAt: string; isOwner: boolean; osUser: string | null; osSshPublicKey: string | null; }; type UsersResponse = { users: ManagedUser[]; /** Every role, for displaying the owner's own value. */ roles: string[]; /** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */ assignableRoles: string[]; ownerId: number; }; const USERS_KEY = ['MANAGED_USERS']; export const UsersSection = () => { const client = useClient(); const queryClient = useQueryClient(); const [pendingId, setPendingId] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const { data, isLoading, isError } = useQuery({ queryKey: USERS_KEY, queryFn: () => client.get('/users'), }); const changeRole = async (user: ManagedUser, role: string) => { if (role === user.role) return; setPendingId(user.id); try { await client.patch(`/users/${user.id}/role`, { role }); await queryClient.invalidateQueries({ queryKey: USERS_KEY }); toast.success(`${user.email} is now ${role}`); } catch (ex) { toast.error(ex instanceof Error ? ex.message : 'Could not change the role'); } finally { setPendingId(null); } }; /** * Create or repair the account's Linux side in place. * * Prompts for a key rather than putting a whole form on the row: replacing it is the rarer of the two * reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it". */ const provisionLinux = async (user: ManagedUser) => { const key = window.prompt( `Linux account for ${user.email}.\n\n` + `Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`, '', ); // Cancel is null; empty string is a deliberate "no change". if (key === null) return; setPendingId(user.id); try { const result = await client.post<{ osUser: string | null; sshPublicKey: string | null; error: string | null }>( `/users/${user.id}/provision-linux`, { sshPublicKey: key.trim() }, ); await queryClient.invalidateQueries({ queryKey: USERS_KEY }); // Partial success is the interesting case and must not read as a clean win: the account can exist and // be confined while the keys failed. if (result.error) { toast.warning(result.osUser ? `${result.osUser} created, but not finished` : 'Could not finish', { description: result.error, duration: 30_000, }); } else { toast.success(`${result.osUser} is ready`); } } catch (ex) { toast.error(ex instanceof Error ? ex.message : 'Could not provision the Linux account'); } finally { setPendingId(null); } }; const remove = async (user: ManagedUser) => { setPendingId(user.id); setConfirmDelete(null); try { await client.delete(`/users/${user.id}`); await queryClient.invalidateQueries({ queryKey: USERS_KEY }); toast.success(`${user.email} removed`); } catch (ex) { toast.error(ex instanceof Error ? ex.message : 'Could not remove the account'); } finally { setPendingId(null); } }; if (isLoading) { return (
Loading accounts…
); } if (isError || !data) { return
Could not load the accounts.
; } return (

Every account on this server. The owner is fixed — the database itself refuses to demote or remove it — so that row cannot be changed from here, and no other account can be promoted into it.

{data.users.map((user) => { const busy = pendingId === user.id; return (
{user.name || user.username || user.email} {user.isOwner && }
{user.email} {user.status !== 'Active' && ` · ${user.status}`} {/* Shown because "does this person have a Linux account" is otherwise invisible, and it decides whether their terminal and agent run as them or not at all. */} {user.osUser && ` · ${user.osUser}`}
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for anyone who has one (retry after fixing a host problem, or replace their key) — the underlying operation is idempotent, so there is no state where pressing it is wrong. */} {!user.isOwner && ( )} {/* The errand the create screen promised would still be here: this key has to end up on their Gitea account, and nothing else will remind anyone. */} {user.osSshPublicKey && ( )}
); })}
{/* Removal cascades — passkeys, dashboards, screens, email accounts, playlists — and there is no undo, so it is worth one deliberate confirmation. */} !open && setConfirmDelete(null)}> Remove {confirmDelete?.email}? This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, email accounts and playlists. It cannot be undone. Cancel confirmDelete && void remove(confirmDelete)} > Remove account
); };