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.
179 lines
7.6 KiB
TypeScript
179 lines
7.6 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { Loader2 } from 'lucide-react';
|
|
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.
|
|
//
|
|
// The screen is one role at a time on purpose. A grid of every role against every permission is the
|
|
// obvious design and it is the wrong one: it invites reading across rows, which is not a question anyone
|
|
// has, and it makes the destructive action ("uncheck Gitea for Members") a single click among fifty. One
|
|
// role, an explicit Save, and a visible dirty state instead.
|
|
|
|
type PermissionInfo = {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
routes: string[];
|
|
hasPersonalWrites: boolean;
|
|
};
|
|
|
|
type Grant = { role: string; permission: string; level: 'read' | 'write' };
|
|
|
|
type PermissionsResponse = {
|
|
/** Grantable AND installed. What this server can currently do. */
|
|
permissions: PermissionInfo[];
|
|
|
|
roles: string[];
|
|
grants: Grant[];
|
|
};
|
|
|
|
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);
|
|
const [draft, setDraft] = useState<Record<string, Level>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const { data, isLoading, isError } = useQuery<PermissionsResponse>({
|
|
queryKey: PERMISSIONS_KEY,
|
|
queryFn: () => client.get<PermissionsResponse>('/users/permissions'),
|
|
});
|
|
|
|
const activeRole = role ?? data?.roles[0] ?? null;
|
|
|
|
// What the server currently says, for this role. The comparison baseline for the dirty state below.
|
|
const saved = useMemo(() => {
|
|
const levels: Record<string, Level> = {};
|
|
for (const permission of data?.permissions ?? []) levels[permission.key] = 'none';
|
|
for (const grant of data?.grants ?? []) {
|
|
if (grant.role === activeRole) levels[grant.permission] = grant.level;
|
|
}
|
|
return levels;
|
|
}, [data, activeRole]);
|
|
|
|
// Reset the draft whenever the role changes or the server answer arrives, so switching roles never
|
|
// carries an unsaved edit across to a role it was not meant for.
|
|
useEffect(() => setDraft(saved), [saved]);
|
|
|
|
const dirty = useMemo(() => Object.keys(saved).some((key) => (draft[key] ?? 'none') !== saved[key]), [draft, saved]);
|
|
|
|
const save = async () => {
|
|
if (!activeRole) return;
|
|
setSaving(true);
|
|
try {
|
|
const grants = Object.entries(draft)
|
|
.filter(([, level]) => level !== 'none')
|
|
.map(([permission, level]) => ({ permission, level }));
|
|
await client.put(`/users/permissions/${encodeURIComponent(activeRole)}`, { grants });
|
|
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_KEY });
|
|
// 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(t('settings.users.permissionsSaved', { role: activeRole }));
|
|
} catch (ex) {
|
|
toast.error(ex instanceof Error ? ex.message : 'Could not save');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
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" /> {t('settings.users.loadingPermissions')}
|
|
</div>
|
|
);
|
|
}
|
|
if (isError || !data) {
|
|
return <div className="p-6 text-sm text-destructive">{t('settings.users.permissionsFailed')}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 p-1">
|
|
{/* Tabs rather than a dropdown. There are three roles and they are the axis you move along — a select
|
|
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={t('settings.users.role')}
|
|
>
|
|
{data.roles.map((r) => (
|
|
<button
|
|
key={r}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeRole === r}
|
|
onClick={() => setRole(r)}
|
|
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
|
activeRole === r
|
|
? 'bg-accent font-medium text-accent-foreground'
|
|
: 'text-muted-foreground hover:bg-accent/50'
|
|
}`}
|
|
>
|
|
{r}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<Button onClick={save} disabled={!dirty || saving}>
|
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{dirty ? 'Save changes' : 'Saved'}
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('settings.users.deniedUnlessGranted')} <strong>{t('settings.users.read')}</strong> allows viewing, plus
|
|
changes to things that are only ever the person’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">
|
|
{data.permissions.map((permission) => {
|
|
const level = draft[permission.key] ?? 'none';
|
|
return (
|
|
<div key={permission.key} className="flex items-center justify-between gap-4 p-3">
|
|
{/* Label only. The descriptions went because with three rows called Terminal, Chat and Files
|
|
they explained nothing anyone needed — and the "needs a Linux account" line went with them:
|
|
every account gets one at creation, so warning about it on every row was noise about a state
|
|
that no longer occurs on its own. */}
|
|
<div className="min-w-0 text-sm font-medium">{permission.label}</div>
|
|
<Select
|
|
value={level}
|
|
onValueChange={(value) => setDraft((prev) => ({ ...prev, [permission.key]: value as Level }))}
|
|
>
|
|
<SelectTrigger className="w-32 shrink-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<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>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Two explanatory blocks used to sit here: one naming every permission whose sidecar is not installed,
|
|
and one naming everything that can never be granted. Both are gone, and for the same reason — a
|
|
server should not enumerate what it does not have. The first was a catalogue of uninstallable
|
|
features presented as a permissions decision; the second described chat, tasks, the desktop and the
|
|
wallet to an owner who may have none of them installed. What is on this screen is what this server
|
|
can actually do. */}
|
|
</div>
|
|
);
|
|
};
|