a naked platform does not describe what it does not have
Reversing my own call from an hour ago. I built the denied-route screen to EXPLAIN the absence — "Music is not installed", with a link to the app store — and argued a redirect erases what you asked for. The owner's correction is the better principle: a server should not know about a sidecar it does not have. Explaining Music is the app describing a feature that, as far as this install is concerned, does not exist, and it leaks the whole catalogue of what could be installed to any member who types a URL. So a denied path is now indistinguishable from an unknown one: redirect home, the same answer App.tsx's path="*" already gave. One behaviour for a member without a grant, an owner without the sidecar, and a typo. Nothing disclosed. The Permissions screen loses both explanatory blocks for the same reason. One listed every capability whose sidecar is absent — a catalogue of uninstallable features presented as a permissions decision. The other described chat, tasks, the desktop and the wallet as "not grantable" to an owner who may have none of them installed. `notInstalled` is gone from the API too, not just hidden in the UI. What is on that screen is what this server can actually do. Still short of what the owner described, and worth naming rather than implying otherwise: routes are DECLARED in App.tsx for every screen and this hides the ones that should not resolve. The end state is routes REGISTERED from the manifests of installed sidecars, so an uninstalled feature has no route to hide. The manifests already exist and the dock is already built from them; the router is not, yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,61 +1,36 @@
|
|||||||
import { useLocation, Link } from 'react-router';
|
import { Navigate, useLocation } from 'react-router';
|
||||||
import { PackageOpen, Lock } from 'lucide-react';
|
|
||||||
import { useCapabilities } from 'hooks/useCapabilities';
|
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
|
// ── Why a redirect and not an explanation ──
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Deliberately NOT a redirect. Sending someone to `/` erases what they asked for and reads as a bug — they
|
// The first version of this rendered a panel saying "Music is not installed" with a link to the app store,
|
||||||
// clicked Music and landed on Home. Saying "Music is not installed" answers the question they actually have,
|
// on the reasoning that a redirect erases what you asked for. That was wrong, and the owner's correction is
|
||||||
// and the URL stays put so a reload after installing it just works.
|
// 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
|
// So "no such page" is the honest answer, and it is the same answer for a member without a grant, for an
|
||||||
// stops the app promising something it will then refuse.
|
// 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 }) {
|
export function RouteGate({ children }: { children?: React.ReactNode }) {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { denialReason, isOwner } = useCapabilities();
|
const { denialReason } = useCapabilities();
|
||||||
const reason = denialReason(pathname);
|
|
||||||
|
|
||||||
if (!reason) return <>{children}</>;
|
// `replace`, so Back does not bounce between the denied path and home.
|
||||||
|
if (denialReason(pathname)) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
const name = pathname.split('/').filter(Boolean)[0] ?? 'This';
|
return <>{children}</>;
|
||||||
const label = name.charAt(0).toUpperCase() + name.slice(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center p-6">
|
|
||||||
<div className="flex max-w-md flex-col items-center gap-3 text-center">
|
|
||||||
{reason === 'not-installed' ? (
|
|
||||||
<>
|
|
||||||
<PackageOpen className="h-8 w-8 text-muted-foreground" />
|
|
||||||
<div className="text-lg font-medium">{label} is not installed</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Nothing on this server provides it yet.
|
|
||||||
{isOwner ? ' Install it and this page starts working.' : ' Ask the server owner to install it.'}
|
|
||||||
</p>
|
|
||||||
{/* Only offered to the owner: the app store is owner-only, so a member following this link would
|
|
||||||
meet a second refusal. */}
|
|
||||||
{isOwner && (
|
|
||||||
<Link to="/app-store" className="rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent">
|
|
||||||
Open the app store
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Lock className="h-8 w-8 text-muted-foreground" />
|
|
||||||
<div className="text-lg font-medium">{label} is not available to you</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Your role does not include it. The server owner decides this under Settings → User management.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-33
@@ -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, PackageOpen } from 'lucide-react';
|
import { Loader2 } 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';
|
||||||
@@ -29,8 +29,7 @@ type Grant = { role: string; capability: string; level: 'read' | 'write' };
|
|||||||
type CapabilitiesResponse = {
|
type CapabilitiesResponse = {
|
||||||
/** Grantable AND installed. What this server can currently do. */
|
/** 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[];
|
||||||
};
|
};
|
||||||
@@ -164,36 +163,12 @@ export const PermissionsSection = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Absent because nothing is installed, not because they cannot be shared — a different sentence from
|
{/* Two explanatory blocks used to sit here: one naming every capability whose sidecar is not installed,
|
||||||
the one below, and the two used to be indistinguishable (both were simply missing). */}
|
and one naming everything that can never be granted. Both are gone, and for the same reason — a
|
||||||
{data.notInstalled.length > 0 && (
|
server should not enumerate what it does not have. The first was a catalogue of uninstallable
|
||||||
<div className="flex gap-3 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
features presented as a permissions decision; the second described chat, tasks, the desktop and the
|
||||||
<PackageOpen className="mt-0.5 h-4 w-4 shrink-0" />
|
wallet to an owner who may have none of them installed. What is on this screen is what this server
|
||||||
<div>
|
can actually do. */}
|
||||||
<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
|
|
||||||
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. */}
|
|
||||||
<div className="flex gap-3 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
|
||||||
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-foreground">Not listed, and not grantable</div>
|
|
||||||
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.
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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),
|
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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user