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:
2026-08-07 01:00:47 +00:00
co-authored by Claude Opus 5
parent 7700e8b540
commit 537a77d320
4 changed files with 276 additions and 3 deletions
@@ -1,9 +1,10 @@
import { useMemo } from 'react';
import { Users, UserCog } from 'lucide-react';
import { Users, UserCog, ShieldCheck } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UsersSection } from './UsersSection';
import { PermissionsSection } from './PermissionsSection';
// Owner-only. Every endpoint behind this screen is gated by ownerGate in users-router.ts, and the
// global backstop already confines a non-owner token to /api/auth + /api/music — so a Member reaching
@@ -18,6 +19,13 @@ const sections: SettingsSection[] = [
description: 'Who has access, and as what',
content: <UsersSection />,
},
{
key: 'permissions',
icon: ShieldCheck,
title: 'Permissions',
description: 'What each role can reach',
content: <PermissionsSection />,
},
];
const { Sidebar, Content } = createSettingsPanelComponents({