Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
T
pastilhas 7f8455f1af 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.
2026-08-23 05:10:17 +00:00

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&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">
{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>
);
};