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:
@@ -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);
|
||||
|
||||
+34
-5
@@ -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’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’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>
|
||||
|
||||
Reference in New Issue
Block a user