From e393d0f5c2b04aa285330e2cfd52c765e25350be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 11 Aug 2026 18:13:03 +0000 Subject: [PATCH] a member's screens render, and the shell stops asking for things it cannot have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Dashboard/Layout/Header/JobsIndicator.tsx | 28 +++++++++++-- .../Dashboard/Layout/Rescan/RescanButton.tsx | 7 ++++ .../UserManagement/PermissionsSection.tsx | 39 ++++++++++++++--- src/servers/api/users/capabilities-routes.ts | 42 ++++++++++++++----- src/servers/app-store/availability.ts | 14 ++++++- src/servers/app-store/catalogue.ts | 14 +++++++ src/servers/capabilities/registry.ts | 15 +++++-- .../src/MusicPlayer/MusicPlayerHost.tsx | 7 ++++ src/workspaces/state/src/useAccessPolicy.ts | 5 ++- src/workspaces/state/src/useDashboardState.ts | 18 +++++++- src/workspaces/state/src/useModels.ts | 6 ++- src/workspaces/state/src/usePlans.ts | 4 +- src/workspaces/state/src/useServerSettings.ts | 7 ++++ 13 files changed, 177 insertions(+), 29 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx index 22072fef..64924d25 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx @@ -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({ 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('/jobs/counts').then((c) => alive && setCounts(c)).catch(() => {}); + const load = () => + client + .get('/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'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx index 3cd6ff48..ea4412f5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx @@ -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 }; @@ -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); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx index 0684ce6f..1ab766c5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx @@ -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 = () => {
{capability.label}
{capability.description}
+ {/* 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 && ( +
+ Needs a Linux account — grant does nothing until the member has one +
+ )}