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(null); const [draft, setDraft] = useState>({}); const [saving, setSaving] = useState(false); const { data, isLoading, isError } = useQuery({ queryKey: PERMISSIONS_KEY, queryFn: () => client.get('/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 = {}; 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 (
Loading capabilities…
); } if (isError || !data) { return
Could not load capabilities.
; } return (
{/* 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. */}
{data.roles.map((r) => ( ))}

Everything is denied unless granted here. Read allows viewing, plus changes to things that are only ever the person’s own — their favourites, their playlists, their devices. Full allows everything within the app.

{data.capabilities.map((capability) => { const level = draft[capability.key] ?? 'none'; return (
{/* 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. */}
{capability.label}
); })}
{/* 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. */}
); };