Accounts, permissions and the create-user form. The confirm dialogs for removing an account and resetting a password were multi-line JSX the scanner cannot see, so they were converted on sight rather than from its list. Cancel comes from ui.button.cancel in all four places it appears here.
365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
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<number | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | null>(null);
|
|
const [confirmReset, setConfirmReset] = useState<ManagedUser | null>(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<UsersResponse>({
|
|
queryKey: USERS_KEY,
|
|
queryFn: () => client.get<UsersResponse>('/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 (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingAccounts')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError || !data) {
|
|
return <div className="text-sm text-destructive">{t('settings.users.accountsFailed')}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
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.
|
|
</p>
|
|
|
|
<CreateUserForm roles={data.assignableRoles} usersKey={USERS_KEY} />
|
|
|
|
<div className="rounded-lg border divide-y">
|
|
{data.users.map((user) => {
|
|
const busy = pendingId === user.id;
|
|
return (
|
|
<div key={user.id} className="flex items-center gap-3 p-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-sm font-medium">{user.name || user.username || user.email}</span>
|
|
{user.isOwner && (
|
|
<Crown
|
|
className="h-3.5 w-3.5 shrink-0 text-duck-teal"
|
|
aria-label={t('settings.users.serverOwner')}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{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}`}
|
|
</div>
|
|
</div>
|
|
|
|
<Select
|
|
value={user.role}
|
|
disabled={user.isOwner || busy}
|
|
onValueChange={(role) => void changeRole(user, role)}
|
|
>
|
|
<SelectTrigger className="w-40 shrink-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{/* The owner's row needs its own value present to render at all, and it is disabled
|
|
anyway. Every other row offers only what the server will accept. */}
|
|
{(user.isOwner ? data.roles : data.assignableRoles).map((role) => (
|
|
<SelectItem key={role} value={role}>
|
|
{role}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 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 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
|
|
disabled={busy}
|
|
aria-label={
|
|
user.osUser ? t('settings.users.repairFor', { email: user.email }) : t('settings.users.createLinux')
|
|
}
|
|
title={
|
|
user.osUser
|
|
? t('settings.users.linuxAccountFor', { osUser: user.osUser })
|
|
: t('settings.users.noLinux')
|
|
}
|
|
onClick={() => void provisionLinux(user)}
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <TerminalIcon className="h-4 w-4" />}
|
|
</Button>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground"
|
|
aria-label={t('settings.users.copySshFor', { email: user.email })}
|
|
title={t('settings.users.copySshTitle')}
|
|
onClick={() => {
|
|
void copyToClipboard(user.osSshPublicKey!);
|
|
toast.success(t('settings.users.publicKeyCopied'));
|
|
}}
|
|
>
|
|
<KeyRound className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground"
|
|
disabled={busy}
|
|
aria-label={t('settings.users.resetPasswordFor', { email: user.email })}
|
|
title={t('settings.users.resetPasswordTitle')}
|
|
onClick={() => setConfirmReset(user)}
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
disabled={user.isOwner || busy}
|
|
onClick={() => setConfirmDelete(user)}
|
|
aria-label={t('settings.users.removeFor', { email: user.email })}
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t('settings.users.removeTitle', { email: confirmDelete?.email ?? '' })}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>{t('settings.users.removeBody')}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
onClick={() => confirmDelete && void remove(confirmDelete)}
|
|
>
|
|
{t('settings.users.removeAccount')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 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. */}
|
|
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('settings.users.resetTitle', { email: confirmReset?.email ?? '' })}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('settings.users.resetBody')}
|
|
{confirmReset?.osUser ? ` ${t('settings.users.resetLinuxNote', { osUser: confirmReset.osUser })}` : null}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
|
|
{t('settings.users.generateNewPassword')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 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. */}
|
|
<AlertDialog open={!!newPassword} onOpenChange={(open) => !open && setNewPassword(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>New password for {newPassword?.email}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
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.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
|
|
<div className="flex items-center gap-2 rounded-md border bg-muted/50 p-3">
|
|
<code className="flex-1 select-all break-all font-mono text-sm">{newPassword?.password}</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0"
|
|
aria-label={t('settings.users.copyNewPassword')}
|
|
onClick={() => {
|
|
if (!newPassword) return;
|
|
void copyToClipboard(newPassword.password).then((ok) =>
|
|
ok ? toast.success(t('settings.users.passwordCopied')) : toast.error(t('settings.users.copyFailed')),
|
|
);
|
|
}}
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<AlertDialogFooter>
|
|
<AlertDialogAction onClick={() => setNewPassword(null)}>{t('settings.users.done')}</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
};
|