Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
T
pastilhasandClaude Opus 5 f0af7237db terminal, chat and files are granted by default; permissions screen simplified
DEFAULTS. Every role now starts with the three confined capabilities at write, seeded in
bootstrap. These are what the platform is FOR — an account that signs in and reaches none of
them is not restricted, it is useless, and making the owner grant them by hand first is a
step with no decision in it.

Seeded as real rows rather than implied by absence, which keeps the table's one rule intact:
a missing row means no access, always, with no exception to remember. Revoking one therefore
works like revoking anything else — the row goes and nothing puts it back. Done in bootstrap
because that happens exactly once per install, so seeding can never fight a later revocation.
Non-fatal: an owner whose roles hold nothing is a one-click fix, while failing bootstrap over
it leaves a platform with no account at all.

`app` capabilities are deliberately not defaulted — they reach data the owner may not intend
to share, and each needs a sidecar before it means anything.

SCREEN. Role selection is tabs rather than a dropdown: three roles are the axis you move
along, and a select hid two of them behind a click while giving no sense of which one you are
editing. Row descriptions are gone — with three rows called Terminal, Chat and Files they
explained nothing — and the "needs a Linux account" warning went with them, since every
account now gets one at creation, so it was noise about a state that no longer occurs on its
own. `needsOsAccount` is removed from the API too, not just hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:25:31 +00:00

173 lines
7.3 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 { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// What each ROLE may reach. Not each user — see the table comment in schema/capabilities.ts for why.
//
// The screen is one role at a time on purpose. A grid of every role against every capability 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 CapabilityInfo = {
key: string;
label: string;
description: string;
routes: string[];
hasPersonalWrites: boolean;
};
type Grant = { role: string; capability: string; level: 'read' | 'write' };
type CapabilitiesResponse = {
/** Grantable AND installed. What this server can currently do. */
capabilities: CapabilityInfo[];
roles: string[];
grants: Grant[];
};
type Level = 'none' | 'read' | 'write';
const PERMISSIONS_KEY = ['ROLE_CAPABILITIES'];
export const PermissionsSection = () => {
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<CapabilitiesResponse>({
queryKey: PERMISSIONS_KEY,
queryFn: () => client.get<CapabilitiesResponse>('/users/capabilities'),
});
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 capability of data?.capabilities ?? []) levels[capability.key] = 'none';
for (const grant of data?.grants ?? []) {
if (grant.role === activeRole) levels[grant.capability] = 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(([capability, level]) => ({ capability, level }));
await client.put(`/users/capabilities/${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: CAPABILITIES_QUERY_KEY });
toast.success(`Saved what ${activeRole}s can reach`);
} 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" /> Loading capabilities
</div>
);
}
if (isError || !data) {
return <div className="p-6 text-sm text-destructive">Could not load capabilities.</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="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">
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.
</p>
<div className="divide-y rounded-lg border">
{data.capabilities.map((capability) => {
const level = draft[capability.key] ?? 'none';
return (
<div key={capability.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">{capability.label}</div>
<Select
value={level}
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No access</SelectItem>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Full</SelectItem>
</SelectContent>
</Select>
</div>
);
})}
</div>
{/* Two explanatory blocks used to sit here: one naming every capability 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>
);
};