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
@@ -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>