user management keyed (267 -> 214)

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.
This commit is contained in:
2026-08-23 05:10:17 +00:00
parent f6b9ddc877
commit 7f8455f1af
5 changed files with 185 additions and 77 deletions
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
//
@@ -65,6 +66,7 @@ type CreatedAccount = {
};
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
@@ -118,16 +120,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
<div>
<h3 className="text-sm font-medium">{created.email} created</h3>
<p className="text-xs text-muted-foreground">
Copy what you need before closing — none of it can be shown again.
</p>
<p className="text-xs text-muted-foreground">{t('settings.users.copyBeforeClosing')}</p>
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<Label>{t('settings.users.password')}</Label>
<div className="flex gap-2">
<Input readOnly value={created.password} className="font-mono" />
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copy(created.password, t('settings.users.password'))}
>
<Copy className="h-4 w-4" />
</Button>
</div>
@@ -139,14 +144,14 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
{created.osUser && (
<div className="space-y-1.5">
<Label>Linux account</Label>
<Label>{t('settings.users.linuxAccount')}</Label>
<Input readOnly value={created.osUser} className="font-mono" />
</div>
)}
{created.osSshPublicKey && (
<div className="space-y-1.5">
<Label>Their SSH public key</Label>
<Label>{t('settings.users.sshKey')}</Label>
<div className="flex gap-2">
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
<Button
@@ -162,20 +167,20 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
permission error that says nothing about a missing key. */}
<p className="text-xs text-muted-foreground">
Generated on the machine; the private half never leaves it.{' '}
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
<strong>{t('settings.users.addToGitea')}</strong> so they can push. Retrievable later from their row.
</p>
</div>
)}
{created.osUserError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="font-medium">The account works, but its Linux side did not finish</div>
<div className="font-medium">{t('settings.users.linuxIncomplete')}</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
</div>
)}
<Button type="button" onClick={close}>
Done
{t('settings.users.done')}
</Button>
</div>
);
@@ -185,7 +190,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add account
{t('settings.users.addAccount')}
</Button>
);
}
@@ -194,19 +199,19 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-medium">New account</h3>
<h3 className="text-sm font-medium">{t('settings.users.newAccount')}</h3>
<p className="text-xs text-muted-foreground">
Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards.
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel">
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label={tUi('button.cancel')}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-user-email">Email</Label>
<Label htmlFor="new-user-email">{t('settings.users.email')}</Label>
<Input
id="new-user-email"
type="email"
@@ -218,7 +223,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-name">Name</Label>
<Label htmlFor="new-user-name">{t('settings.users.name')}</Label>
<Input
id="new-user-name"
autoComplete="off"
@@ -229,7 +234,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-username">Username</Label>
<Label htmlFor="new-user-username">{t('settings.users.username')}</Label>
<Input
id="new-user-username"
autoComplete="off"
@@ -237,11 +242,11 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
onChange={(ev) => set('username')(ev.target.value)}
required
/>
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p>
<p className="text-xs text-muted-foreground">{t('settings.users.usernameHint')}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-role">Role</Label>
<Label htmlFor="new-user-role">{t('settings.users.role')}</Label>
<Select value={form.role} onValueChange={set('role')}>
<SelectTrigger id="new-user-role">
<SelectValue />
@@ -261,7 +266,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-password">Password</Label>
<Label htmlFor="new-user-password">{t('settings.users.password')}</Label>
<div className="flex gap-2">
<Input
id="new-user-password"
@@ -276,7 +281,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
/>
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
<Dices className="h-4 w-4" />
<span className="sr-only">Generate a password</span>
<span className="sr-only">{t('settings.users.generatePassword')}</span>
</Button>
<Button
type="button"
@@ -285,29 +290,27 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
disabled={!form.password}
onClick={() => {
void copyToClipboard(form.password);
toast.success('Password copied');
toast.success(t('settings.users.passwordCopied'));
}}
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy the password</span>
<span className="sr-only">{t('settings.users.copyPassword')}</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
At least 12 characters, with upper and lower case, a number and a symbol.
</p>
<p className="text-xs text-muted-foreground">{t('settings.users.passwordHint')}</p>
</div>
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
not replace it, because a key on a laptop is no use to an agent running on the server. */}
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-ssh">
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
{t('settings.users.sshKey')} <span className="ml-1 text-xs opacity-60">(optional)</span>
</Label>
<Textarea
id="new-user-ssh"
rows={3}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
placeholder={t('settings.users.sshPlaceholder')}
className="font-mono text-xs"
value={form.sshPublicKey}
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
@@ -325,7 +328,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
Create account
</Button>
<Button type="button" variant="ghost" onClick={close} disabled={saving}>
Cancel
{tUi('button.cancel')}
</Button>
</div>
</form>
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/lib/i18n';
// What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why.
//
@@ -37,6 +38,7 @@ type Level = 'none' | 'read' | 'write';
const PERMISSIONS_KEY = ['ROLE_PERMISSIONS'];
export const PermissionsSection = () => {
const { t, tUi } = useTranslation();
const client = useClient();
const queryClient = useQueryClient();
const [role, setRole] = useState<string | null>(null);
@@ -78,7 +80,7 @@ export const PermissionsSection = () => {
// The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
// catch up without a reload.
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY });
toast.success(`Saved what ${activeRole}s can reach`);
toast.success(t('settings.users.permissionsSaved', { role: activeRole }));
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not save');
} finally {
@@ -89,12 +91,12 @@ export const PermissionsSection = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading permissions…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingPermissions')}
</div>
);
}
if (isError || !data) {
return <div className="p-6 text-sm text-destructive">Could not load permissions.</div>;
return <div className="p-6 text-sm text-destructive">{t('settings.users.permissionsFailed')}</div>;
}
return (
@@ -103,7 +105,11 @@ export const PermissionsSection = () => {
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
buttons, because switching role mutates a draft rather than navigating. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
<div
className="flex items-center gap-1 rounded-lg border p-1"
role="tablist"
aria-label={t('settings.users.role')}
>
{data.roles.map((r) => (
<button
key={r}
@@ -128,9 +134,9 @@ export const PermissionsSection = () => {
</div>
<p className="text-xs text-muted-foreground">
Everything is denied unless granted here. <strong>Read</strong> allows viewing, plus changes to things that are
only ever the person&rsquo;s own — their favourites, their playlists, their devices.
<strong> Full</strong> allows everything within the app.
{t('settings.users.deniedUnlessGranted')} <strong>{t('settings.users.read')}</strong> allows viewing, plus
changes to things that are only ever the person&rsquo;s own — their favourites, their playlists, their devices.
<strong> {t('settings.users.full')}</strong> allows everything within the app.
</p>
<div className="divide-y rounded-lg border">
@@ -151,9 +157,9 @@ export const PermissionsSection = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No access</SelectItem>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Full</SelectItem>
<SelectItem value="none">{t('settings.users.noAccess')}</SelectItem>
<SelectItem value="read">{t('settings.users.read')}</SelectItem>
<SelectItem value="write">{t('settings.users.full')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm';
import { copyToClipboard } from 'helpers/clipboard';
import { useTranslation } from '@/lib/i18n';
type ManagedUser = {
id: number;
@@ -43,6 +44,7 @@ type UsersResponse = {
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);
@@ -77,11 +79,7 @@ export const UsersSection = () => {
* 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.`,
'',
);
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;
@@ -147,13 +145,13 @@ export const UsersSection = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading accounts…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.users.loadingAccounts')}
</div>
);
}
if (isError || !data) {
return <div className="text-sm text-destructive">Could not load the accounts.</div>;
return <div className="text-sm text-destructive">{t('settings.users.accountsFailed')}</div>;
}
return (
@@ -173,7 +171,12 @@ export const UsersSection = () => {
<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="Server owner" />}
{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}
@@ -212,11 +215,13 @@ export const UsersSection = () => {
size="icon"
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
disabled={busy}
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`}
aria-label={
user.osUser ? t('settings.users.repairFor', { email: user.email }) : t('settings.users.createLinux')
}
title={
user.osUser
? `Linux account: ${user.osUser} — click to repair or replace their SSH key`
: 'No Linux account — click to create one'
? t('settings.users.linuxAccountFor', { osUser: user.osUser })
: t('settings.users.noLinux')
}
onClick={() => void provisionLinux(user)}
>
@@ -231,11 +236,11 @@ export const UsersSection = () => {
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
aria-label={`Copy ${user.email}'s SSH public key`}
title="Copy their SSH public key (add it to their Gitea account)"
aria-label={t('settings.users.copySshFor', { email: user.email })}
title={t('settings.users.copySshTitle')}
onClick={() => {
void copyToClipboard(user.osSshPublicKey!);
toast.success('Public key copied');
toast.success(t('settings.users.publicKeyCopied'));
}}
>
<KeyRound className="h-4 w-4" />
@@ -250,8 +255,8 @@ export const UsersSection = () => {
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Reset ${user.email}'s password`}
title="Generate a new password — shown once, and signs them out everywhere"
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" />}
@@ -264,7 +269,7 @@ export const UsersSection = () => {
className="shrink-0 text-muted-foreground hover:text-destructive"
disabled={user.isOwner || busy}
onClick={() => setConfirmDelete(user)}
aria-label={`Remove ${user.email}`}
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>
@@ -282,19 +287,18 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove {confirmDelete?.email}?</AlertDialogTitle>
<AlertDialogDescription>
This deletes the account and everything belonging to it — passkeys, dashboards, saved layouts, and
anything an installed plugin keeps for them. It cannot be undone.
</AlertDialogDescription>
<AlertDialogTitle>
{t('settings.users.removeTitle', { email: confirmDelete?.email ?? '' })}
</AlertDialogTitle>
<AlertDialogDescription>{t('settings.users.removeBody')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => confirmDelete && void remove(confirmDelete)}
>
Remove account
{t('settings.users.removeAccount')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -305,23 +309,16 @@ export const UsersSection = () => {
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle>
<AlertDialogTitle>{t('settings.users.resetTitle', { email: confirmReset?.email ?? '' })}</AlertDialogTitle>
<AlertDialogDescription>
A new password is generated and shown to you once — it is not stored anywhere and cannot be looked up
afterwards. Their existing password stops working immediately, and they are signed out everywhere.
{confirmReset?.osUser ? (
<>
{' '}
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
unchanged.
</>
) : null}
{t('settings.users.resetBody')}
{confirmReset?.osUser ? ` ${t('settings.users.resetLinuxNote', { osUser: confirmReset.osUser })}` : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
Generate a new password
{t('settings.users.generateNewPassword')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -345,11 +342,11 @@ export const UsersSection = () => {
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Copy the new password"
aria-label={t('settings.users.copyNewPassword')}
onClick={() => {
if (!newPassword) return;
void copyToClipboard(newPassword.password).then((ok) =>
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'),
ok ? toast.success(t('settings.users.passwordCopied')) : toast.error(t('settings.users.copyFailed')),
);
}}
>
@@ -358,7 +355,7 @@ export const UsersSection = () => {
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction>
<AlertDialogAction onClick={() => setNewPassword(null)}>{t('settings.users.done')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>