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'),