capabilities: the dock a member sees, and the screen the owner grants from
useCapabilities is the frontend's view of the model and explicitly NOT its enforcement — hiding a dock icon is a courtesy, the 403 in origin-validation is the lock. so it fails OPEN: if the request errors the full dock renders. a member clicking through to a 403 is a bad minute; an owner locked out of their own platform by a transient network error is an incident, and the server refuses what it should refuse either way. the endpoint returns held routes AND denied routes, because absence from the held list cannot distinguish a route this account lacks from one no capability claims at all — `/`, the settings shell — and a guard that cannot tell those apart either blanks the app or guards nothing. i wrote the first version without the second list and it silently permitted everything. `can` and `canVisit` are memoised on the query data. a verb rebuilt every render gets a new identity every render, which is how every playback report in the jellyfin player was disabled for days; the dock filter puts one in a useMemo dependency list, so it would have been the same bug. the permissions screen is one role at a time, with an explicit save and a dirty state, rather than a roles-by-capabilities grid — a grid invites reading across rows, which is not a question anyone has, and makes revoking gitea for every member one click among fifty. it also states plainly why terminal, chat, files and the rest are absent, so their absence reads as a decision rather than as a missing feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+170
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Lock } 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 = {
|
||||
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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Role</span>
|
||||
<Select value={activeRole ?? undefined} onValueChange={(value) => setRole(value)}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data.roles.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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’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">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{capability.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{capability.description}</div>
|
||||
</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>
|
||||
|
||||
{/* Stated rather than silently omitted. An owner who cannot find the Terminal checkbox will assume
|
||||
the screen is incomplete and go looking for it; saying why it does not exist is the difference
|
||||
between a deliberate design and a missing feature. */}
|
||||
<div className="flex gap-3 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Not listed, and not grantable</div>
|
||||
The terminal, chat, tasks, files, the code editor, the desktop and the browser all run as the server owner, in
|
||||
the server owner’s home directory, with full permissions. Granting one of them would hand over the
|
||||
machine rather than a feature, so there is no level at which they can be shared. The wallet, Headscale and the
|
||||
server settings stay with the owner for the same reason.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user