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 { Link } from 'react-router';
|
||||||
import { Loader2, ListOrdered } from 'lucide-react';
|
import { Loader2, ListOrdered } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
type Counts = { running: number; runningJobId: string | null; queued: number };
|
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 = () => {
|
export const JobsIndicator = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const { can } = useCapabilities();
|
||||||
|
const allowed = can('tasks');
|
||||||
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
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();
|
load();
|
||||||
const timer = setInterval(load, 3000);
|
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';
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
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() {
|
export function RescanButton() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { can } = useCapabilities();
|
||||||
const [loading, setLoading] = useState(false);
|
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 () => {
|
const rescan = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
+34
-5
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Loader2, Lock } from 'lucide-react';
|
import { Loader2, Lock, PackageOpen } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
|
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,12 +20,17 @@ type CapabilityInfo = {
|
|||||||
description: string;
|
description: string;
|
||||||
routes: string[];
|
routes: string[];
|
||||||
hasPersonalWrites: boolean;
|
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 Grant = { role: string; capability: string; level: 'read' | 'write' };
|
||||||
|
|
||||||
type CapabilitiesResponse = {
|
type CapabilitiesResponse = {
|
||||||
|
/** Grantable AND installed. What this server can currently do. */
|
||||||
capabilities: CapabilityInfo[];
|
capabilities: CapabilityInfo[];
|
||||||
|
/** Grantable, but no sidecar installed — listed so their absence reads as a fact, not a bug. */
|
||||||
|
notInstalled: CapabilityInfo[];
|
||||||
roles: string[];
|
roles: string[];
|
||||||
grants: Grant[];
|
grants: Grant[];
|
||||||
};
|
};
|
||||||
@@ -133,6 +138,13 @@ export const PermissionsSection = () => {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium">{capability.label}</div>
|
<div className="text-sm font-medium">{capability.label}</div>
|
||||||
<div className="text-xs text-muted-foreground">{capability.description}</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>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={level}
|
value={level}
|
||||||
@@ -152,6 +164,19 @@ export const PermissionsSection = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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
|
{/* 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
|
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. */}
|
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" />
|
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground">Not listed, and not grantable</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
|
Chat, tasks, capability authoring, the desktop and the browser run as the server owner, in the server
|
||||||
the server owner’s home directory, with full permissions. Granting one of them would hand over the
|
owner’s home directory, with full permissions. Granting one would hand over the machine rather than a
|
||||||
machine rather than a feature, so there is no level at which they can be shared. The wallet, Headscale and the
|
feature, so there is no level at which they can be shared. The wallet, Headscale and the server settings stay
|
||||||
server settings stay with the owner for the same reason.
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,17 +76,39 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
|||||||
export const capabilityAdminRouter = createRouter();
|
export const capabilityAdminRouter = createRouter();
|
||||||
|
|
||||||
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
||||||
return ctx.json({
|
// Only what this server can actually do RIGHT NOW.
|
||||||
// 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.
|
// The same subtraction the dock already makes, applied to the granting UI — which was showing all
|
||||||
capabilities: GRANTABLE_CAPABILITIES.map((c) => ({
|
// 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,
|
key: c.key,
|
||||||
label: c.label,
|
label: c.label,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
||||||
routes: c.routes ?? [],
|
routes: c.routes ?? [],
|
||||||
hasPersonalWrites: !!c.personal?.length,
|
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
|
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
|
||||||
// database refuses a row for that role.
|
// database refuses a row for that role.
|
||||||
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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 = {
|
export type Availability = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -110,8 +110,20 @@ export type CatalogueEntry = {
|
|||||||
/**
|
/**
|
||||||
* The capability this sidecar backs, from `capabilities/registry.ts`. Null where the sidecar has no
|
* 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).
|
* 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;
|
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
|
* Asked when the user picks `existing`. Skipped entirely for `provisioned`, where we already know the
|
||||||
* answers because we wrote the compose file.
|
* answers because we wrote the compose file.
|
||||||
@@ -328,6 +340,8 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
members: 'none',
|
members: 'none',
|
||||||
modes: ['existing'],
|
modes: ['existing'],
|
||||||
capability: 'headscale',
|
capability: 'headscale',
|
||||||
|
// A member enrolling their own device is the same sidecar. See `alsoServes`.
|
||||||
|
alsoServes: ['vpn'],
|
||||||
existingFields: [
|
existingFields: [
|
||||||
{ key: 'url', label: 'Headscale URL', type: 'url', required: true },
|
{ key: 'url', label: 'Headscale URL', type: 'url', required: true },
|
||||||
{ key: 'secret', label: 'API key', type: 'secret', required: true },
|
{ key: 'secret', label: 'API key', type: 'secret', required: true },
|
||||||
|
|||||||
@@ -235,11 +235,20 @@ export const CAPABILITIES: Capability[] = [
|
|||||||
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
|
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
|
||||||
personal: ['/'],
|
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',
|
key: 'dashboards',
|
||||||
label: 'Dashboards',
|
label: 'Screen layouts and dashboards',
|
||||||
description: 'Your own dashboards and saved layouts',
|
description: 'Where your own screen layouts and dashboards are saved',
|
||||||
kind: 'app',
|
kind: 'core',
|
||||||
api: ['/dashboards'],
|
api: ['/dashboards'],
|
||||||
routes: ['/dashboards'],
|
routes: ['/dashboards'],
|
||||||
personal: ['/'],
|
personal: ['/'],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router';
|
import { Link, useLocation, useNavigate } from 'react-router';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
||||||
import { MusicHeart } from '../apps/Music/MusicHeart';
|
import { MusicHeart } from '../apps/Music/MusicHeart';
|
||||||
@@ -23,6 +24,8 @@ const MUSIC_API = '/api/music';
|
|||||||
|
|
||||||
export const MusicPlayerHost = () => {
|
export const MusicPlayerHost = () => {
|
||||||
const { token, get, put, delete: del } = useClient();
|
const { token, get, put, delete: del } = useClient();
|
||||||
|
const { can } = useCapabilities();
|
||||||
|
const canUseMusic = can('music');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { current, index, queue, playing, toggle, next, prev, setPlaying, syncIndex, close, loadQueue } =
|
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
|
// 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).
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (restoredRef.current) return;
|
if (restoredRef.current) return;
|
||||||
|
if (!canUseMusic) return;
|
||||||
restoredRef.current = true;
|
restoredRef.current = true;
|
||||||
if (queue.length) return;
|
if (queue.length) return;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
type AccessPolicy = {
|
type AccessPolicy = {
|
||||||
allowedModels: string[];
|
allowedModels: string[];
|
||||||
@@ -12,11 +13,13 @@ const QUERY_KEY = ['ACCESS_POLICY'];
|
|||||||
export function useAccessPolicy() {
|
export function useAccessPolicy() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { isAuthenticated } = useAuth();
|
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 queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({
|
const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({
|
||||||
queryKey: QUERY_KEY,
|
queryKey: QUERY_KEY,
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated && isOwner,
|
||||||
queryFn: () => client.get<AccessPolicy>('/server-settings/chat-providers/access-policy'),
|
queryFn: () => client.get<AccessPolicy>('/server-settings/chat-providers/access-policy'),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
|
|||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
const {
|
||||||
|
data: state = {},
|
||||||
|
isSuccess,
|
||||||
|
isError,
|
||||||
|
} = useQuery<UserState>({
|
||||||
queryKey: QUERY_KEY,
|
queryKey: QUERY_KEY,
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: () => client.get<UserState>('/dashboards'),
|
queryFn: () => client.get<UserState>('/dashboards'),
|
||||||
@@ -72,7 +76,17 @@ export function useDashboardState<T>(key: string, defaultValue: T) {
|
|||||||
[key, defaultValue, queryClient],
|
[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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
import { useAccessPolicy } from './useAccessPolicy';
|
import { useAccessPolicy } from './useAccessPolicy';
|
||||||
import { useSettings } from './useSettings';
|
import { useSettings } from './useSettings';
|
||||||
import type { ModelOption } from 'officerdev';
|
import type { ModelOption } from 'officerdev';
|
||||||
@@ -26,10 +27,13 @@ export function getHostHome(): string {
|
|||||||
export function useModels() {
|
export function useModels() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { isAuthenticated } = useAuth();
|
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[]>({
|
const { data: models = [] } = useQuery<ModelOption[]>({
|
||||||
queryKey: ['CHAT_MODELS'],
|
queryKey: ['CHAT_MODELS'],
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated && can('chat'),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await client.get<{
|
const data = await client.get<{
|
||||||
models: ModelOption[];
|
models: ModelOption[];
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
export const usePlans = () => {
|
export const usePlans = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { can } = useCapabilities();
|
||||||
|
|
||||||
const { data: plans = [] } = useQuery<string[]>({
|
const { data: plans = [] } = useQuery<string[]>({
|
||||||
queryKey: ['PLANS'],
|
queryKey: ['PLANS'],
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated && can('plans'),
|
||||||
queryFn: () => client.get<string[]>('/plans'),
|
queryFn: () => client.get<string[]>('/plans'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
type AIHarnesses = {
|
type AIHarnesses = {
|
||||||
claudeCode: boolean;
|
claudeCode: boolean;
|
||||||
@@ -18,8 +19,14 @@ export const useServerSettings = () => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const { data: settings, isLoading } = useQuery({
|
||||||
queryKey: SETTINGS_KEY,
|
queryKey: SETTINGS_KEY,
|
||||||
|
enabled: isOwner,
|
||||||
queryFn: () => client.get<ServerSettings>('/server-settings/settings'),
|
queryFn: () => client.get<ServerSettings>('/server-settings/settings'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user