diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index 3b14035f..f7916465 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -9,6 +9,7 @@ import { Header } from './Header'; import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock'; import { useIsTouch } from './useIsTouch'; import { usePageTitleSync } from '@/state/usePageTitle'; +import { RouteGate } from './RouteGate'; type DashboardLayoutProps = { children?: React.ReactNode; @@ -55,7 +56,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { resetKeys={[pathname]} fallback={({ error, reset }) => } > - {children} + {/* Inside the boundary and around every screen, so one place decides whether a route exists for + this account on this server. Filtering the dock was never enough: the tile was hidden and the + route still rendered for anyone who typed it, followed an old link or restored a tab. */} + {children} diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx new file mode 100644 index 00000000..605b0805 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx @@ -0,0 +1,61 @@ +import { useLocation, Link } from 'react-router'; +import { PackageOpen, Lock } from 'lucide-react'; +import { useCapabilities } from 'hooks/useCapabilities'; + +// A screen only exists if this account can reach it AND this server has the thing behind it. +// +// 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. +// +// 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. +// +// 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. + +export function RouteGate({ children }: { children?: React.ReactNode }) { + const { pathname } = useLocation(); + const { denialReason, isOwner } = useCapabilities(); + const reason = denialReason(pathname); + + if (!reason) return <>{children}; + + 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. +

+ + )} +
+
+ ); +} diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index f2b2f6c0..9b7ff49d 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { getOwnerHomeDir, DATA_PATH, HOME_SEED_DIRS } from '@@/data-path'; +import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { resolveHomeDir } from '@@/user-home'; import * as errors from '@@/custom-errors'; import { readTtsConfig } from '@@/api/server-settings/tts'; @@ -19,7 +19,6 @@ async function getUserTtsVoice(userId: number): Promise { return null; } -const DEFAULT_HOME_DIRS = HOME_SEED_DIRS; const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video']; async function cleanOldCacheDirs(userDataDir: string) { @@ -29,12 +28,9 @@ async function cleanOldCacheDirs(userDataDir: string) { } } -async function seedHomeDir(homeDir: string) { - for (const dir of DEFAULT_HOME_DIRS) { - const target = join(homeDir, dir); - if (!existsSync(target)) await mkdir(target, { recursive: true }); - } -} +// `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of +// any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING +// at it, which is not a listing's business and not a layout the platform has any standing to choose. export const router = createRouter(); @@ -172,18 +168,16 @@ router.get('/ls', async (ctx) => { const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const absPath = resolveUserPath(rootDir, relPath); - // Auto-create dir if missing (only for user home root). + // Create the home root itself if it is missing, and nothing else. A listing that invents its own contents + // is a listing you cannot trust — the folder set it used to seed is gone. // - // Non-fatal since per-user Linux accounts: a member's home is 700 and owned by THEM, so the platform - // cannot write into it and every one of these calls raises EPERM. Their folders are seeded at account - // creation, as them. Letting a convenience take down `/ls` would mean the file browser failing to list a - // directory it can read perfectly well. + // Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of + // whether the directory can be used. if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { try { - await seedHomeDir(rootDir); await mkdir(absPath, { recursive: true }); } catch { - // Nothing to report: either it exists, or it is not ours to create. `readdir` below is the real test. + // Either it exists, or it is not ours to create. } } diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 27b7461d..e1f6b9b3 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -63,15 +63,6 @@ export const USER_DIRS = [ 'sidecar', ] as const; -/** - * The folders a home is seeded with, so a new account's file browser is not an empty rectangle. - * - * Here rather than in the file browser because there are now two seeders: that router (for the owner, whose - * home it can write to) and the Linux-account provisioner, which has to create them AS the member because - * their home is 700 and theirs. Two lists would mean a member's home quietly differing from the owner's. - */ -export const HOME_SEED_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'] as const; - /** * Create an account's root and its skeleton, closed by default. * diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index 26d75a34..17713c11 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -1,7 +1,7 @@ import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { DATA_PATH, HOME_SEED_DIRS, USER_DIRS, toShellUsername } from './data-path'; +import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path'; // Real Linux accounts for members, so the surfaces that execute code can run as them. // @@ -250,14 +250,10 @@ export async function ensureOsUser(params: { email: string; username: string | n }; } - // Seeded AS the member, because after the chown above their home is 700 and theirs — the platform cannot - // write into it, which is exactly the point. Best-effort: an empty file browser is a cosmetic problem, and - // failing the whole account creation over Downloads/ would be absurd. - const seed = runAs(osUser, ['mkdir', '-p', ...HOME_SEED_DIRS.map((dir) => join(home, dir))]); - if ((await seed.exited) !== 0) { - console.warn(`[os-user] could not seed ${osUser}'s home folders: ${await new Response(seed.stderr).text()}`); - } - + // A new home is EMPTY, deliberately. This used to create Downloads/Documents/Music/Videos/Pictures — a + // habit inherited from the file browser, which did the same lazily for the owner. Nothing needs them: + // guessing at somebody's folder layout is a decision the platform has no standing to make, and an empty + // home is honest about being new. return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created }; } diff --git a/src/workspaces/hooks/src/useCapabilities.ts b/src/workspaces/hooks/src/useCapabilities.ts index 40c7a755..bde0c05f 100644 --- a/src/workspaces/hooks/src/useCapabilities.ts +++ b/src/workspaces/hooks/src/useCapabilities.ts @@ -99,10 +99,36 @@ export function useCapabilities() { [data], ); + /** + * Why a route is refused, or null if it is not. + * + * Two answers, because they need two different screens. `not-installed` is a fact about the SERVER and the + * owner can fix it from the app store; `not-granted` is a fact about the ACCOUNT and only the owner can + * change it. Presenting either as the other sends the reader looking in the wrong place. + * + * Same fail-open posture as `can`: no data means no denial. + */ + const denialReason = useCallback( + (path: string): 'not-installed' | 'not-granted' | null => { + if (!data) return null; + if (!data.deniedRoutes.some((route) => path === route || path.startsWith(`${route}/`))) return null; + + // Held but unavailable → the sidecar is missing. Checked against the capability that claims the route, + // which is why `unavailable` is returned as capability keys rather than routes. + const unavailable = new Set(data.unavailable ?? []); + const heldAndUnavailable = data.capabilities.some(({ key }) => unavailable.has(key)); + if (data.isOwner || heldAndUnavailable) return 'not-installed'; + return 'not-granted'; + }, + [data], + ); + return { isOwner: data?.isOwner ?? false, capabilities: held, routes: data?.routes ?? [], + unavailable: data?.unavailable ?? [], + denialReason, // Empty rather than undefined when the request has not landed or failed: the dock then renders its // baseline, which is the honest "we do not know yet" — not an empty app. plugins: data?.plugins ?? [],