a member's screens render, and the shell stops asking for things it cannot have

Three findings from granting Files to a role and signing in as the member.

THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded
was the success flag of GET /api/dashboards — which the `dashboards` capability gated.
So a member with files granted got a completely blank Files screen and no request to
/api/file-browser at all: the panel never mounted. Terminal, Chat and every other
workspace screen were the same.

/api/dashboards is not a feature. It is the per-user key-value store where every
screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it
does not restrict an account, it breaks it — which is the definition of `core` at the
top of the registry. Moved there.

And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as
well as a successful one, with `loadFailed` for the difference, so a screen that cannot
remember its layout still renders with defaults instead of showing nothing and
explaining nothing.

THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on
capability, so a member's first paint fired 403s at /server-settings/settings,
/jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing
and the chat access policy. Each now checks the capability it needs. JobsIndicator and
RescanButton also render nothing without `tasks` and `items` — the header was offering
two links to a screen the member cannot open and a button that would 403.

THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none
of their sidecars are installed. Offering to grant Photos on a machine with no Immich
is not a permission decision. It now shows only what is installed, lists the rest as
"nothing installed for these yet" so their absence reads as a fact rather than a bug,
and marks confined rows as needing a Linux account. Fails open on a degraded read.

Found while checking that: the headscale catalogue entry claimed only the `headscale`
capability, but the same sidecar also serves `vpn` — a member enrolling their own
device — so vpn was never subtracted. Hence `alsoServes`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:13:03 +00:00
co-authored by Claude Opus 5
parent 2c9d4e55aa
commit e393d0f5c2
13 changed files with 177 additions and 29 deletions
@@ -2,21 +2,41 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
type Counts = { running: number; runningJobId: string | null; queued: number };
// Always-present header badges: how many jobs are running (→ the running job) and queued (→ the queue).
// Header badges: how many jobs are running (→ the running job) and queued (→ the queue).
//
// Shown only to an account that holds `tasks`, which today means the owner — the queue runs scripts as the
// server owner and is `kind: 'execution'`. It used to render for everyone and poll `/jobs/counts` every
// three seconds regardless, so a member's console filled with 403s at 20 a minute and the header offered two
// links to a screen they cannot open. Neither is a security problem; both are the app lying about what it is.
export const JobsIndicator = () => {
const client = useClient();
const { can } = useCapabilities();
const allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
useEffect(() => {
// Guarded inside the effect as well as at the render below, because the timer is the expensive half:
// an early return in the body would still leave an interval running from a previous render.
if (!allowed) return;
let alive = true;
const load = () => client.get<Counts>('/jobs/counts').then((c) => alive && setCounts(c)).catch(() => {});
const load = () =>
client
.get<Counts>('/jobs/counts')
.then((c) => alive && setCounts(c))
.catch(() => {});
load();
const timer = setInterval(load, 3000);
return () => { alive = false; clearInterval(timer); };
}, []);
return () => {
alive = false;
clearInterval(timer);
};
}, [allowed]);
if (!allowed) return null;
const pill = 'flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold tabular-nums transition-colors';
@@ -3,6 +3,7 @@ import { RotateCw } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
type RescanResponse = { ok: boolean; counts: Record<string, number> };
@@ -13,8 +14,14 @@ const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'process
export function RescanButton() {
const client = useClient();
const qc = useQueryClient();
const { can } = useCapabilities();
const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
// owner's disk, `kind: 'execution'`. A member pressing this got a 403 and a red toast about a feature
// whose existence is not their business.
if (!can('items')) return null;
const rescan = async () => {
if (loading) return;
setLoading(true);
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2, Lock } from 'lucide-react';
import { Loader2, Lock, PackageOpen } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
import { Button } from '@/components/ui/button';
@@ -20,12 +20,17 @@ type CapabilityInfo = {
description: string;
routes: string[];
hasPersonalWrites: boolean;
/** Confined: the grant does nothing until the member has a Linux account on this machine. */
needsOsAccount: boolean;
};
type Grant = { role: string; capability: string; level: 'read' | 'write' };
type CapabilitiesResponse = {
/** Grantable AND installed. What this server can currently do. */
capabilities: CapabilityInfo[];
/** Grantable, but no sidecar installed — listed so their absence reads as a fact, not a bug. */
notInstalled: CapabilityInfo[];
roles: string[];
grants: Grant[];
};
@@ -133,6 +138,13 @@ export const PermissionsSection = () => {
<div className="min-w-0">
<div className="text-sm font-medium">{capability.label}</div>
<div className="text-xs text-muted-foreground">{capability.description}</div>
{/* Said on the row rather than in a footnote, because the grant genuinely does nothing
without it and the fix is on the Accounts tab two clicks away. */}
{capability.needsOsAccount && (
<div className="mt-0.5 text-xs text-amber-500">
Needs a Linux account grant does nothing until the member has one
</div>
)}
</div>
<Select
value={level}
@@ -152,6 +164,19 @@ export const PermissionsSection = () => {
})}
</div>
{/* Absent because nothing is installed, not because they cannot be shared — a different sentence from
the one below, and the two used to be indistinguishable (both were simply missing). */}
{data.notInstalled.length > 0 && (
<div className="flex gap-3 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
<PackageOpen className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-foreground">Nothing installed for these yet</div>
{data.notInstalled.map((c) => c.label).join(', ')} each appears here once you install it from the App
store. Granting something this server cannot do would only produce a refusal the person could not explain.
</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. */}
@@ -159,10 +184,14 @@ export const PermissionsSection = () => {
<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&rsquo;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.
Chat, tasks, capability authoring, the desktop and the browser run as the server owner, in the server
owner&rsquo;s home directory, with full permissions. Granting one 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 className="mt-1.5">
Files is the exception, and only because of how it is built: a member with a Linux account on this machine
gets their own home, enforced by the operating system rather than by a check in the app.
</div>
</div>
</div>
</div>
+27 -5
View File
@@ -76,17 +76,39 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
export const capabilityAdminRouter = createRouter();
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
return ctx.json({
// Only the grantable kind is offered. `execution` and `admin` are deliberately not in this list:
// a UI that shows a checkbox it will refuse to honour is worse than one that never offered it.
capabilities: GRANTABLE_CAPABILITIES.map((c) => ({
// Only what this server can actually do RIGHT NOW.
//
// The same subtraction the dock already makes, applied to the granting UI — which was showing all
// fourteen app capabilities on a fresh install where none of their sidecars existed. Offering to grant
// Photos on a machine with no Immich is not a permission decision, it is a menu of things that would
// 403 for a different reason than the owner thinks.
//
// Fail open on a degraded read: `capabilityAvailability` returns an empty `unavailable` set when it
// cannot see install state, so the list falls back to everything rather than to nothing. An owner whose
// Permissions screen emptied itself because one query failed would reasonably conclude the feature broke.
const { unavailable } = await capabilityAvailability();
const describe = (c: (typeof GRANTABLE_CAPABILITIES)[number]) => ({
key: c.key,
label: c.label,
description: c.description,
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
routes: c.routes ?? [],
hasPersonalWrites: !!c.personal?.length,
})),
/** `confined` needs a Linux account per member to mean anything — the UI says so next to the row. */
needsOsAccount: c.kind === 'confined',
});
return ctx.json({
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
// shows a checkbox it will refuse to honour is worse than one that never offered it.
capabilities: GRANTABLE_CAPABILITIES.filter((c) => !unavailable.has(c.key)).map(describe),
/**
* Grantable, but their sidecar is not installed. Returned rather than dropped so the screen can say
* "these appear once you install them" — otherwise an owner who remembers seeing Photos here concludes
* the list is broken, and the honest answer is one sentence.
*/
notInstalled: GRANTABLE_CAPABILITIES.filter((c) => unavailable.has(c.key)).map(describe),
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
// database refuses a row for that role.
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
+12 -2
View File
@@ -21,8 +21,18 @@ import { CATALOGUE, type CatalogueEntry } from './catalogue';
// puts the rule in the UI, where a member's dock and an owner's dock can drift apart, and where a
// third-party plugin would have to be taught about it. Subtracting server-side keeps one answer.
/** Capability key → the sidecar that has to be installed for it to mean anything. */
const CAPABILITY_TO_SIDECAR = new Map(CATALOGUE.filter((e) => e.capability).map((e) => [e.capability as string, e.id]));
/**
* Capability key → the sidecar that has to be installed for it to mean anything.
*
* Includes `alsoServes`, because one sidecar can back more than one capability: Headscale serves both the
* owner's tailnet administration and a member enrolling their own device, and only listing the first left the
* second looking available on a machine that had no Headscale.
*/
const CAPABILITY_TO_SIDECAR = new Map(
CATALOGUE.flatMap((entry) =>
[entry.capability, ...(entry.alsoServes ?? [])].filter((key): key is string => !!key).map((key) => [key, entry.id]),
) as Array<[string, string]>,
);
export type Availability = {
/**
+14
View File
@@ -110,8 +110,20 @@ export type CatalogueEntry = {
/**
* The capability this sidecar backs, from `capabilities/registry.ts`. Null where the sidecar has no
* user-facing surface of its own (notify produces notifications for other features).
*
* This is also the key the sidecar's dock manifest is filtered by, which is why it is one value and not a
* list — a tile belongs to one feature.
*/
capability: string | null;
/**
* Other capabilities that stop working when this sidecar is absent, for availability only.
*
* Headscale is the case: one sidecar serves both `headscale` (administering the tailnet, owner-only) and
* `vpn` (a member enrolling their own device). With only `capability` to go on, `vpn` was never subtracted,
* so the Permissions screen offered it on a machine with no Headscale at all — a grant that would have
* produced a refusal the owner could not account for.
*/
alsoServes?: string[];
/**
* Asked when the user picks `existing`. Skipped entirely for `provisioned`, where we already know the
* answers because we wrote the compose file.
@@ -328,6 +340,8 @@ export const CATALOGUE: CatalogueEntry[] = [
members: 'none',
modes: ['existing'],
capability: 'headscale',
// A member enrolling their own device is the same sidecar. See `alsoServes`.
alsoServes: ['vpn'],
existingFields: [
{ key: 'url', label: 'Headscale URL', type: 'url', required: true },
{ key: 'secret', label: 'API key', type: 'secret', required: true },
+12 -3
View File
@@ -235,11 +235,20 @@ export const CAPABILITIES: Capability[] = [
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
personal: ['/'],
},
// Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is
// the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`,
// `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it
// meant a member with `files` granted got a completely blank Files screen and no request to
// /api/file-browser at all — the panel never mounted. Same for Terminal, Chat and every other screen.
//
// It is entirely `personal` and always was: every row is keyed to the caller. There is nothing here to
// withhold, and withholding it does not restrict an account, it breaks it — which is exactly the
// definition of `core` at the top of this file.
{
key: 'dashboards',
label: 'Dashboards',
description: 'Your own dashboards and saved layouts',
kind: 'app',
label: 'Screen layouts and dashboards',
description: 'Where your own screen layouts and dashboards are saved',
kind: 'core',
api: ['/dashboards'],
routes: ['/dashboards'],
personal: ['/'],
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { MusicHeart } from '../apps/Music/MusicHeart';
@@ -23,6 +24,8 @@ const MUSIC_API = '/api/music';
export const MusicPlayerHost = () => {
const { token, get, put, delete: del } = useClient();
const { can } = useCapabilities();
const canUseMusic = can('music');
const navigate = useNavigate();
const { pathname } = useLocation();
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
@@ -119,8 +122,12 @@ export const MusicPlayerHost = () => {
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
//
// Also skipped without the `music` capability. This host is mounted by the shell for every account, so it
// used to reach for `/music/now-playing` on a member's very first paint and 403.
useEffect(() => {
if (restoredRef.current) return;
if (!canUseMusic) return;
restoredRef.current = true;
if (queue.length) return;
(async () => {
+4 -1
View File
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useCapabilities } from 'hooks/useCapabilities';
type AccessPolicy = {
allowedModels: string[];
@@ -12,11 +13,13 @@ const QUERY_KEY = ['ACCESS_POLICY'];
export function useAccessPolicy() {
const client = useClient();
const { isAuthenticated } = useAuth();
// Under `/server-settings`, so `server-admin`: owner only. Authentication alone was never the right gate.
const { isOwner } = useCapabilities();
const queryClient = useQueryClient();
const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
enabled: isAuthenticated && isOwner,
queryFn: () => client.get<AccessPolicy>('/server-settings/chat-providers/access-policy'),
staleTime: 5 * 60 * 1000,
});
+16 -2
View File
@@ -16,7 +16,11 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {}, isSuccess } = useQuery<UserState>({
const {
data: state = {},
isSuccess,
isError,
} = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/dashboards'),
@@ -72,7 +76,17 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
[key, defaultValue, queryClient],
);
return { key, value, setValue, isLoaded: isSuccess };
// `isLoaded` is true on a FAILED fetch as well as a successful one, and the distinction is `loadFailed`.
//
// Consumers use `isLoaded` to decide whether to render at all — `WorkspaceView` returns null without it.
// While that meant only "the request is in flight" it was fine. The moment `/dashboards` could answer 403,
// it meant a permanently blank screen: no layout, no panels, no request to the feature the screen is for,
// and nothing on screen to say why. A screen that cannot remember its layout should still BE a screen.
//
// So a failed load resolves to the caller's `defaultValue` and renders. `loadFailed` is exposed so a
// consumer can say "not saved" rather than pretend, and `setValue` still attempts its PATCH — if the
// failure was transient the write succeeds, and if it was a 403 the existing revert path reports it.
return { key, value, setValue, isLoaded: isSuccess || isError, loadFailed: isError };
}
/**
+5 -1
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useCapabilities } from 'hooks/useCapabilities';
import { useAccessPolicy } from './useAccessPolicy';
import { useSettings } from './useSettings';
import type { ModelOption } from 'officerdev';
@@ -26,10 +27,13 @@ export function getHostHome(): string {
export function useModels() {
const client = useClient();
const { isAuthenticated } = useAuth();
// `/chat` is `kind: 'execution'` — the agent runs unsandboxed as the server owner, so it is owner-only and
// never grantable. The model list is no use to anyone who cannot open a chat.
const { can } = useCapabilities();
const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['CHAT_MODELS'],
enabled: isAuthenticated,
enabled: isAuthenticated && can('chat'),
queryFn: async () => {
const data = await client.get<{
models: ModelOption[];
+3 -1
View File
@@ -1,14 +1,16 @@
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { useQuery } from '@tanstack/react-query';
export const usePlans = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { can } = useCapabilities();
const { data: plans = [] } = useQuery<string[]>({
queryKey: ['PLANS'],
enabled: isAuthenticated,
enabled: isAuthenticated && can('plans'),
queryFn: () => client.get<string[]>('/plans'),
});
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
type AIHarnesses = {
claudeCode: boolean;
@@ -18,8 +19,14 @@ export const useServerSettings = () => {
const client = useClient();
const queryClient = useQueryClient();
// `/server-settings` is the `server-admin` capability: owner only, and not grantable at any level. This
// hook is mounted by shell components that every account loads, so without the guard a member's first
// paint fired a 403 at it.
const { isOwner } = useCapabilities();
const { data: settings, isLoading } = useQuery({
queryKey: SETTINGS_KEY,
enabled: isOwner,
queryFn: () => client.get<ServerSettings>('/server-settings/settings'),
});