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:
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from './useClient';
|
||||
|
||||
// What the signed-in account may reach, as the frontend sees it.
|
||||
//
|
||||
// THIS IS NOT ACCESS CONTROL. Every answer here is a courtesy: it stops the app offering a member a
|
||||
// Terminal icon that would 403, and stops a screen mounting a panel whose every request will fail. The
|
||||
// lock is server-side, in origin-validation's capability backstop and the websocket gate — both of which
|
||||
// hold regardless of what this hook returns, including when it returns nothing because the request failed.
|
||||
//
|
||||
// Which is why the failure mode below is deliberately generous rather than restrictive: if this request
|
||||
// errors we show the full dock rather than an empty one. 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. The
|
||||
// server refuses what it should refuse either way.
|
||||
|
||||
export type CapabilityLevel = 'read' | 'write';
|
||||
|
||||
export type SelfCapabilities = {
|
||||
isOwner: boolean;
|
||||
capabilities: { key: string; level: CapabilityLevel }[];
|
||||
/** Frontend route prefixes the account holds, flattened across its capabilities. */
|
||||
routes: string[];
|
||||
/**
|
||||
* Route prefixes claimed by capabilities the account does NOT hold. Both lists are needed: absence from
|
||||
* `routes` cannot tell a denied route from one no capability claims (`/`, the settings shell), and a
|
||||
* guard that cannot tell those apart either blanks the app or guards nothing.
|
||||
*/
|
||||
deniedRoutes: string[];
|
||||
};
|
||||
|
||||
export const CAPABILITIES_QUERY_KEY = ['self-capabilities'];
|
||||
|
||||
export function useCapabilities() {
|
||||
const client = useClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery<SelfCapabilities>({
|
||||
queryKey: CAPABILITIES_QUERY_KEY,
|
||||
queryFn: () => client.get<SelfCapabilities>('/user/capabilities'),
|
||||
// Grants change rarely and only by an owner action, but they change the shape of the whole app when
|
||||
// they do. A minute matches the `tasks` / `task-categories` caches the rest of the app uses.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const held = useMemo(() => new Map((data?.capabilities ?? []).map((c) => [c.key, c.level])), [data]);
|
||||
|
||||
// `can` and `canVisit` are memoised on `data` alone, and that matters more than it looks. A verb rebuilt
|
||||
// every render gets a new identity every render, so anything putting one in a useCallback or useMemo
|
||||
// dependency list silently recomputes forever — which is exactly how every playback report in the
|
||||
// Jellyfin player was disabled for days by an unmount cleanup re-running mid-playback. `data` is a React
|
||||
// Query value with a stable reference between fetches, so these change only when the answer does.
|
||||
|
||||
/** Whether the account holds a capability, optionally at write level. */
|
||||
const can = useCallback(
|
||||
(key: string, level: CapabilityLevel = 'read'): boolean => {
|
||||
if (!data) return true; // see the note above: fail open, the server does not
|
||||
if (data.isOwner) return true;
|
||||
const granted = held.get(key);
|
||||
if (!granted) return false;
|
||||
return level === 'read' || granted === 'write';
|
||||
},
|
||||
[data, held],
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a frontend route is reachable. Only routes a capability actually claims are ever denied —
|
||||
* `/`, the settings shell and anything else unclaimed stays open, because denying by default here would
|
||||
* blank the app for everyone rather than restrict it for anyone.
|
||||
*/
|
||||
const canVisit = useCallback(
|
||||
(path: string): boolean => {
|
||||
if (!data) return true;
|
||||
if (data.isOwner) return true;
|
||||
return !data.deniedRoutes.some((route) => path === route || path.startsWith(`${route}/`));
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return {
|
||||
isOwner: data?.isOwner ?? false,
|
||||
capabilities: held,
|
||||
routes: data?.routes ?? [],
|
||||
isLoading,
|
||||
isError,
|
||||
can,
|
||||
canVisit,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user