diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx index 605b0805..f785b52a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx @@ -1,61 +1,36 @@ -import { useLocation, Link } from 'react-router'; -import { PackageOpen, Lock } from 'lucide-react'; +import { Navigate, useLocation } from 'react-router'; import { useCapabilities } from 'hooks/useCapabilities'; -// A screen only exists if this account can reach it AND this server has the thing behind it. +// A screen exists only if this server has the thing behind it and this account may reach it. Otherwise the +// path is treated exactly as an unknown one: redirect home, same as App.tsx's `path="*"`. // -// Until this existed, `canVisit` filtered the dock and nothing else — so the icon was hidden and the ROUTE -// was wide open. Typing /music, following an old link or restoring a tab rendered the Music screen for an -// account with no music capability on a server with no music sidecar: an empty library, a spinner, and a -// handful of 403s in the console. The refusal has to be at the route, because that is where the reader -// arrives. +// ── Why a redirect and not an explanation ── // -// Deliberately NOT a redirect. Sending someone to `/` erases what they asked for and reads as a bug — they -// clicked Music and landed on Home. Saying "Music is not installed" answers the question they actually have, -// and the URL stays put so a reload after installing it just works. +// The first version of this rendered a panel saying "Music is not installed" with a link to the app store, +// on the reasoning that a redirect erases what you asked for. That was wrong, and the owner's correction is +// the better principle: a naked platform should not know about a sidecar it does not have. Explaining the +// absence of Music is the app describing a feature that, as far as this server is concerned, does not exist — +// and it leaks the whole catalogue of what could be installed to every member who types a URL. // -// This is a courtesy, not the lock. Every route here is refused server-side as well; hiding the screen only -// stops the app promising something it will then refuse. +// So "no such page" is the honest answer, and it is the same answer for a member without a grant, for an +// owner whose sidecar is not installed, and for a typo. One behaviour, nothing disclosed. +// +// This is still a courtesy rather than the lock — every one of these routes is refused server-side too. What +// it stops is the app offering a door it will then slam. +// +// ── What this is NOT ── +// +// Routes are still declared in App.tsx for every screen, and this hides the ones that should not resolve. The +// end state the owner described is different and better: routes REGISTERED from the manifests of installed +// sidecars, so an uninstalled feature has no route to hide. The manifests already exist (`plugins`, carrying +// `rootRoute` and `routes`) and the dock is already built from them; the router is not, yet. export function RouteGate({ children }: { children?: React.ReactNode }) { const { pathname } = useLocation(); - const { denialReason, isOwner } = useCapabilities(); - const reason = denialReason(pathname); + const { denialReason } = useCapabilities(); - if (!reason) return <>{children}; + // `replace`, so Back does not bounce between the denied path and home. + if (denialReason(pathname)) return ; - const name = pathname.split('/').filter(Boolean)[0] ?? 'This'; - const label = name.charAt(0).toUpperCase() + name.slice(1); - - return ( -
-
- {reason === 'not-installed' ? ( - <> - -
{label} is not installed
-

- Nothing on this server provides it yet. - {isOwner ? ' Install it and this page starts working.' : ' Ask the server owner to install it.'} -

- {/* Only offered to the owner: the app store is owner-only, so a member following this link would - meet a second refusal. */} - {isOwner && ( - - Open the app store - - )} - - ) : ( - <> - -
{label} is not available to you
-

- Your role does not include it. The server owner decides this under Settings → User management. -

- - )} -
-
- ); + return <>{children}; } 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 1ab766c5..484c5be5 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, PackageOpen } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities'; import { Button } from '@/components/ui/button'; @@ -29,8 +29,7 @@ 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[]; }; @@ -164,36 +163,12 @@ export const PermissionsSection = () => { })} - {/* 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 && ( -
- -
-
Nothing installed for these yet
- {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. -
-
- )} - - {/* 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
- 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. -
- 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. -
-
-
+ {/* Two explanatory blocks used to sit here: one naming every capability whose sidecar is not installed, + and one naming everything that can never be granted. Both are gone, and for the same reason — a + server should not enumerate what it does not have. The first was a catalogue of uninstallable + features presented as a permissions decision; the second described chat, tasks, the desktop and the + wallet to an owner who may have none of them installed. What is on this screen is what this server + can actually do. */} ); }; diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/capabilities-routes.ts index 3d8944ec..9e668494 100644 --- a/src/servers/api/users/capabilities-routes.ts +++ b/src/servers/api/users/capabilities-routes.ts @@ -103,12 +103,6 @@ capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => { // 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 // database refuses a row for that role. roles: USER_ROLES.filter((r) => r !== 'Super Admin'),