import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, 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'; import { copyToClipboard } from 'helpers/clipboard'; import { useTranslation } from '@/lib/i18n'; 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 { t, tUi } = useTranslation(); const client = useClient(); const queryClient = useQueryClient(); const [pendingId, setPendingId] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [confirmReset, setConfirmReset] = useState(null); /** The one and only sighting of a generated password. Cleared when the dialog closes, and gone for good. */ const [newPassword, setNewPassword] = useState<{ email: string; password: string } | null>(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(t('settings.users.provisionPrompt', { email: user.email }), ''); // 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); } }; /** * A new platform password, generated by the server and shown once. * * Generated rather than typed because the failure this exists for is "I forgot to copy it down", and an * owner typing a replacement can lose it the same way twice. Only the argon2 hash is stored, so the * dialog below really is the only time anyone sees it — which is why it is a dialog and not a toast. */ const resetPassword = async (user: ManagedUser) => { setPendingId(user.id); setConfirmReset(null); try { const result = await client.post<{ email: string; password: string }>(`/users/${user.id}/password`, {}); setNewPassword(result); await queryClient.invalidateQueries({ queryKey: USERS_KEY }); } catch (ex) { toast.error(ex instanceof Error ? ex.message : 'Could not reset the password'); } 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 (
{t('settings.users.loadingAccounts')}
); } if (isError || !data) { return
{t('settings.users.accountsFailed')}
; } 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 && ( )} {/* The owner is excluded because they have change-password, which asks for the current one — and resetting themselves from here would sign them out of the session doing it. */} {!user.isOwner && ( )}
); })}
{/* Removal cascades — passkeys, dashboards, screens — and there is no undo, so it is worth one deliberate confirmation. It used to enumerate email accounts and playlists too. Both tables belong to plugins now, so the cascade reaches them only where that plugin is installed — and promising it unconditionally is a promise the platform cannot keep. */} !open && setConfirmDelete(null)}> {t('settings.users.removeTitle', { email: confirmDelete?.email ?? '' })} {t('settings.users.removeBody')} {tUi('button.cancel')} confirmDelete && void remove(confirmDelete)} > {t('settings.users.removeAccount')} {/* Confirmed rather than immediate: this ends every session the account has, including one they may be in the middle of using. Not destructive enough for the red button, so it keeps the default. */} !open && setConfirmReset(null)}> {t('settings.users.resetTitle', { email: confirmReset?.email ?? '' })} {t('settings.users.resetBody')} {confirmReset?.osUser ? ` ${t('settings.users.resetLinuxNote', { osUser: confirmReset.osUser })}` : null} {tUi('button.cancel')} confirmReset && void resetPassword(confirmReset)}> {t('settings.users.generateNewPassword')} {/* The only time this password is ever visible. A dialog rather than a toast for exactly that reason: a toast that times out while somebody is finding a pen loses the thing they came for. */} !open && setNewPassword(null)}> New password for {newPassword?.email} Copy it now and give it to them. Only its hash is stored, so closing this dialog is the last anyone sees of it — if it is lost, generate another one.
{newPassword?.password}
setNewPassword(null)}>{t('settings.users.done')}
); };