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 +
+ )}