diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index 49eccf24..90ba0d36 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -1,5 +1,6 @@ -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { useDock, MusicPlayerHost } from 'officerdev'; +import { useCapabilities } from 'hooks/useCapabilities'; import { Background } from './Background'; import { Header } from './Header'; import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock'; @@ -10,7 +11,13 @@ type DashboardLayoutProps = { children?: React.ReactNode; }; export function DashboardLayout({ children }: DashboardLayoutProps) { - const { items: visibleItems } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS); + const { canVisit } = useCapabilities(); + // Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer + // reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic + // either way — every one of these routes is refused server-side too — but an app that offers a door it + // will then slam is worse than one that never showed it. + const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]); + const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS); const isTouch = useIsTouch(); usePageTitleSync(); // The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx new file mode 100644 index 00000000..0684ce6f --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Loader2, Lock } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +// What each ROLE may reach. Not each user — see the table comment in schema/capabilities.ts for why. +// +// The screen is one role at a time on purpose. A grid of every role against every capability is the +// obvious design and it is the wrong one: it invites reading across rows, which is not a question anyone +// has, and it makes the destructive action ("uncheck Gitea for Members") a single click among fifty. One +// role, an explicit Save, and a visible dirty state instead. + +type CapabilityInfo = { + key: string; + label: string; + description: string; + routes: string[]; + hasPersonalWrites: boolean; +}; + +type Grant = { role: string; capability: string; level: 'read' | 'write' }; + +type CapabilitiesResponse = { + capabilities: CapabilityInfo[]; + roles: string[]; + grants: Grant[]; +}; + +type Level = 'none' | 'read' | 'write'; + +const PERMISSIONS_KEY = ['ROLE_CAPABILITIES']; + +export const PermissionsSection = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const [role, setRole] = useState(null); + const [draft, setDraft] = useState>({}); + const [saving, setSaving] = useState(false); + + const { data, isLoading, isError } = useQuery({ + queryKey: PERMISSIONS_KEY, + queryFn: () => client.get('/users/capabilities'), + }); + + const activeRole = role ?? data?.roles[0] ?? null; + + // What the server currently says, for this role. The comparison baseline for the dirty state below. + const saved = useMemo(() => { + const levels: Record = {}; + for (const capability of data?.capabilities ?? []) levels[capability.key] = 'none'; + for (const grant of data?.grants ?? []) { + if (grant.role === activeRole) levels[grant.capability] = grant.level; + } + return levels; + }, [data, activeRole]); + + // Reset the draft whenever the role changes or the server answer arrives, so switching roles never + // carries an unsaved edit across to a role it was not meant for. + useEffect(() => setDraft(saved), [saved]); + + const dirty = useMemo(() => Object.keys(saved).some((key) => (draft[key] ?? 'none') !== saved[key]), [draft, saved]); + + const save = async () => { + if (!activeRole) return; + setSaving(true); + try { + const grants = Object.entries(draft) + .filter(([, level]) => level !== 'none') + .map(([capability, level]) => ({ capability, level })); + await client.put(`/users/capabilities/${encodeURIComponent(activeRole)}`, { grants }); + await queryClient.invalidateQueries({ queryKey: PERMISSIONS_KEY }); + // The owner may be editing their own view's inputs — and anyone already signed in needs the dock to + // catch up without a reload. + await queryClient.invalidateQueries({ queryKey: CAPABILITIES_QUERY_KEY }); + toast.success(`Saved what ${activeRole}s can reach`); + } catch (ex) { + toast.error(ex instanceof Error ? ex.message : 'Could not save'); + } finally { + setSaving(false); + } + }; + + if (isLoading) { + return ( +
+ Loading capabilities… +
+ ); + } + if (isError || !data) { + return
Could not load capabilities.
; + } + + return ( +
+
+
+ Role + +
+ +
+ +

+ Everything is denied unless granted here. Read allows viewing, plus changes to things that are + only ever the person’s own — their favourites, their playlists, their devices. + Full allows everything within the app. +

+ +
+ {data.capabilities.map((capability) => { + const level = draft[capability.key] ?? 'none'; + return ( +
+
+
{capability.label}
+
{capability.description}
+
+ +
+ ); + })} +
+ + {/* 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. */} +
+ +
+
Not listed, and not grantable
+ 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. +
+
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx index e30aeb75..eb26611b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/index.tsx @@ -1,9 +1,10 @@ import { useMemo } from 'react'; -import { Users, UserCog } from 'lucide-react'; +import { Users, UserCog, ShieldCheck } from 'lucide-react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceLayout } from 'officerdev'; import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel'; import { UsersSection } from './UsersSection'; +import { PermissionsSection } from './PermissionsSection'; // Owner-only. Every endpoint behind this screen is gated by ownerGate in users-router.ts, and the // global backstop already confines a non-owner token to /api/auth + /api/music — so a Member reaching @@ -18,6 +19,13 @@ const sections: SettingsSection[] = [ description: 'Who has access, and as what', content: , }, + { + key: 'permissions', + icon: ShieldCheck, + title: 'Permissions', + description: 'What each role can reach', + content: , + }, ]; const { Sidebar, Content } = createSettingsPanelComponents({ diff --git a/src/workspaces/hooks/src/useCapabilities.ts b/src/workspaces/hooks/src/useCapabilities.ts new file mode 100644 index 00000000..a17560a9 --- /dev/null +++ b/src/workspaces/hooks/src/useCapabilities.ts @@ -0,0 +1,88 @@ +import { useCallback, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useClient } from './useClient'; + +// What the signed-in account may reach, as the frontend sees it. +// +// THIS IS NOT ACCESS CONTROL. Every answer here is a courtesy: it stops the app offering a member a +// Terminal icon that would 403, and stops a screen mounting a panel whose every request will fail. The +// lock is server-side, in origin-validation's capability backstop and the websocket gate — both of which +// hold regardless of what this hook returns, including when it returns nothing because the request failed. +// +// Which is why the failure mode below is deliberately generous rather than restrictive: if this request +// errors we show the full dock rather than an empty one. A member clicking through to a 403 is a bad +// minute; an owner locked out of their own platform by a transient network error is an incident. The +// server refuses what it should refuse either way. + +export type CapabilityLevel = 'read' | 'write'; + +export type SelfCapabilities = { + isOwner: boolean; + capabilities: { key: string; level: CapabilityLevel }[]; + /** Frontend route prefixes the account holds, flattened across its capabilities. */ + routes: string[]; + /** + * Route prefixes claimed by capabilities the account does NOT hold. Both lists are needed: absence from + * `routes` cannot tell a denied route from one no capability claims (`/`, the settings shell), and a + * guard that cannot tell those apart either blanks the app or guards nothing. + */ + deniedRoutes: string[]; +}; + +export const CAPABILITIES_QUERY_KEY = ['self-capabilities']; + +export function useCapabilities() { + const client = useClient(); + + const { data, isLoading, isError } = useQuery({ + queryKey: CAPABILITIES_QUERY_KEY, + queryFn: () => client.get('/user/capabilities'), + // Grants change rarely and only by an owner action, but they change the shape of the whole app when + // they do. A minute matches the `tasks` / `task-categories` caches the rest of the app uses. + staleTime: 60_000, + }); + + const held = useMemo(() => new Map((data?.capabilities ?? []).map((c) => [c.key, c.level])), [data]); + + // `can` and `canVisit` are memoised on `data` alone, and that matters more than it looks. A verb rebuilt + // every render gets a new identity every render, so anything putting one in a useCallback or useMemo + // dependency list silently recomputes forever — which is exactly how every playback report in the + // Jellyfin player was disabled for days by an unmount cleanup re-running mid-playback. `data` is a React + // Query value with a stable reference between fetches, so these change only when the answer does. + + /** Whether the account holds a capability, optionally at write level. */ + const can = useCallback( + (key: string, level: CapabilityLevel = 'read'): boolean => { + if (!data) return true; // see the note above: fail open, the server does not + if (data.isOwner) return true; + const granted = held.get(key); + if (!granted) return false; + return level === 'read' || granted === 'write'; + }, + [data, held], + ); + + /** + * Whether a frontend route is reachable. Only routes a capability actually claims are ever denied — + * `/`, the settings shell and anything else unclaimed stays open, because denying by default here would + * blank the app for everyone rather than restrict it for anyone. + */ + const canVisit = useCallback( + (path: string): boolean => { + if (!data) return true; + if (data.isOwner) return true; + return !data.deniedRoutes.some((route) => path === route || path.startsWith(`${route}/`)); + }, + [data], + ); + + return { + isOwner: data?.isOwner ?? false, + capabilities: held, + routes: data?.routes ?? [], + isLoading, + isError, + can, + canVisit, + }; +}