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>
173 lines
7.3 KiB
TypeScript
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’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>
|
|
);
|
|
};
|