From 89ef4b5ff690ba11a73226afd9d5d6253a0dd7ce Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 14 Oct 2026 22:22:50 -0500 Subject: [PATCH] feat(shell): Settings hierarchy, Discover, and /settings/ The Settings foundation (ADR 0061): - URL model. /settings is a real landing page. The platform sections are /settings/general, /branding, /displays, and /system. Module lifecycle is Discover: /settings/discover/browse, /installed, and /updates. /settings/discover is respelled in place to Browse's one canonical address. A module's settings are /settings/, never nested under Discover. Deeper Settings addresses are reserved. "Modules" stays the user-facing word: "Discover modules", "Installed modules", "Module updates". - Collisions. Core's one reserved list gains the Settings section names (branding, discover, displays, general, system) as a second documented group, so no module id can shadow a section; `modules` is an ordinary id. apps/api tests hold it equal to the shell's sections. - Navigation. One Settings item in the same sidebar expands in place into Platform and Modules (and Discover's three views) while in Settings, and the phone drawer has the same hierarchy. The rest of the navigation stays. - Permissions. GET /api/v1/dashboard/settings evaluates, through Core's evaluator, which destinations the viewer may see: - Displays: core.devices.manage - Discover: core.modules.lifecycle.manage - a module's settings: core.modules.configuration.manage, for each module that declares configuration (ADR 0029) - General, Branding, System: the platform owner, until each has its own permission These are presentation hints; every data route keeps its own enforcement. With nothing visible, there is no Settings link. - Pages are honest placeholders with Back to Settings. A disabled module's settings say it is turned off; a section or module the person cannot see says so without detail; an unknown name is not found; a failed load withholds the link and offers Retry. - The deploy smoke opens Settings by deep link, refreshes a section, enters Discover, opens the module's settings, uses the phone drawer, and returns to the Dashboard. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 + apps/api/src/app.ts | 14 + apps/api/src/dashboard-settings-routes.ts | 133 ++++++ apps/api/tests/dashboard-host-routes.test.ts | 18 +- .../tests/dashboard-settings-routes.test.ts | 248 ++++++++++ core/src/modules/discovery.ts | 6 +- core/src/modules/reserved-ids.ts | 49 +- core/tests/module-discovery.test.ts | 47 +- ...57-dashboard-urls-are-host-owned-routes.md | 4 + ...gs-hierarchy-and-module-settings-routes.md | 177 +++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 8 +- docs/module-development/README.md | 10 +- scripts/lib/deploy-smoke-browser.mjs | 74 +++ surfaces/shell/src/browser/HostApp.tsx | 64 ++- surfaces/shell/src/browser/SettingsPages.tsx | 248 ++++++++++ surfaces/shell/src/browser/ShellLayout.tsx | 19 +- .../shell/src/browser/ShellNavigation.tsx | 200 +++++++- .../shell/src/browser/settings-catalog.ts | 198 ++++++++ surfaces/shell/src/browser/shell-icons.tsx | 18 + surfaces/shell/src/browser/shell-styles.ts | 49 ++ surfaces/shell/src/dashboard-routes.ts | 106 ++++- surfaces/shell/src/host-protocol.ts | 99 +++- surfaces/shell/src/index.ts | 4 + surfaces/shell/tests/dashboard-routes.test.ts | 80 +++- surfaces/shell/tests/host-app.test.tsx | 2 + surfaces/shell/tests/host-protocol.test.ts | 57 ++- surfaces/shell/tests/host-routing.test.tsx | 7 +- surfaces/shell/tests/return-target.test.ts | 8 +- .../shell/tests/settings-navigation.test.tsx | 437 ++++++++++++++++++ 30 files changed, 2343 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/dashboard-settings-routes.ts create mode 100644 apps/api/tests/dashboard-settings-routes.test.ts create mode 100644 docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md create mode 100644 surfaces/shell/src/browser/SettingsPages.tsx create mode 100644 surfaces/shell/src/browser/settings-catalog.ts create mode 100644 surfaces/shell/tests/settings-navigation.test.tsx diff --git a/README.md b/README.md index 08f17f2..93b72f1 100644 --- a/README.md +++ b/README.md @@ -944,6 +944,14 @@ designed rather than inverted, every text and focus pairing is contrast-tested t schemes, corners scale from one `radius.scale` token, and an explicit Light or Dark choice is one call away once account preferences exist. Displays stay light. +**Settings** has its own place in the sidebar +([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a +landing page at `/settings`; the platform's General, Branding, Displays, Discover, and System; and each +module's settings at `/settings/`. Discover is where modules are found, installed, managed, +and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring a module is its own +Settings page. A person sees only what the server says they may administer, and no Settings link at all +when that is nothing. The pages are placeholders until each section is built. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9248840..4f6dc89 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -31,6 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; +import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js"; import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js"; import type { DisplayHostAssets } from "./display-host-assets.js"; import { createGrantsRoutes } from "./grants-routes.js"; @@ -455,6 +456,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono { }), ); + // Which Settings destinations the viewer may see (ADR 0061): server-evaluated presentation hints + // beside the navigation, never authority. See `./dashboard-settings-routes.js`. + app.route( + "/", + createDashboardSettingsRoutes({ + evaluator: options.authorization.evaluator, + platformOwners: options.authorization.store, + registry: options.authorization.registry, + runtime, + snapshot: options.moduleLifecycle.snapshot, + }), + ); + // Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/` // belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it // rather than inside it. See `./module-lifecycle-routes.js`. diff --git a/apps/api/src/dashboard-settings-routes.ts b/apps/api/src/dashboard-settings-routes.ts new file mode 100644 index 0000000..ed5be25 --- /dev/null +++ b/apps/api/src/dashboard-settings-routes.ts @@ -0,0 +1,133 @@ +import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts"; +import { + type AuthorizationPlatformOwnerPort, + CORE_DEVICES_MANAGE, + CORE_MODULES_CONFIGURATION_MANAGE, + CORE_MODULES_LIFECYCLE_MANAGE, + CORE_MODULES_UI_READ, +} from "@ekklesia/core/authorization"; +import type { ModuleLifecycleSnapshot } from "@ekklesia/core/modules"; +import { SETTINGS_SECTIONS, type SettingsSection } from "@ekklesia/shell/dashboard-routes"; +import { Hono } from "hono"; +import { + type AuthorizationMiddlewareOptions, + getAuthorizedPrincipal, + requirePermission, +} from "./authorization-middleware.js"; + +export const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings"; + +/** One module's settings destination: its id (the `/settings/` segment), its display + * name, and whether it is running. A disabled or blocked module is listed so its address can say it + * is unavailable, and flagged so the Dashboard never offers it in navigation. */ +export interface DashboardSettingsModuleView { + readonly id: string; + readonly name: string; + readonly available: boolean; +} + +export interface DashboardSettingsResponse { + /** The platform sections this viewer may see, in navigation order. */ + readonly sections: readonly SettingsSection[]; + /** Modules whose settings this viewer may see, by name. */ + readonly modules: readonly DashboardSettingsModuleView[]; +} + +/** + * Who may see each platform section (ADR 0061). + * + * A section whose administration already has a Core permission uses it, so it is delegable today. + * General, Branding, and System have no permission yet — nothing in them exists to authorize — so + * they are shown only to the **platform owner**, the one principal who already holds every + * permission; each gains its own delegable permission in the work that builds it. + */ +const SECTION_ACCESS: Readonly> = + Object.freeze({ + branding: "platform-owner", + // Finding, installing, turning on and off, and updating modules: deciding which modules run. + discover: CORE_MODULES_LIFECYCLE_MANAGE, + displays: CORE_DEVICES_MANAGE, + general: "platform-owner", + system: "platform-owner", + }); + +export interface CreateDashboardSettingsRoutesOptions extends AuthorizationMiddlewareOptions { + /** The boot snapshot: every module this process discovered, and which it started. A module's + * settings exist when its manifest declares configuration (ADR 0029). */ + readonly snapshot: ModuleLifecycleSnapshot; + readonly platformOwners: AuthorizationPlatformOwnerPort; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * The Dashboard's Settings navigation, evaluated for the viewer. + * + * Like the module permission hints in the navigation (ADR 0026), these are **presentation hints, + * not authority**: they decide which Settings destinations the Dashboard offers, so nobody is shown + * administration they cannot carry out (DESIGN.md §9.2). Every route behind a section keeps its own + * enforcement, unchanged. Each answer goes through Core's one evaluator — baselines, grants, expiry, + * and the owner bypass behave exactly as on a real request — and an evaluation failure is a `503`, + * never an empty answer that would look like a permissions problem. + * + * Reading requires `core.modules.ui.read`, the same permission the navigation it sits beside + * requires: any signed-in person may ask, and most will be told there is nothing for them. + */ +export function createDashboardSettingsRoutes(options: CreateDashboardSettingsRoutesOptions): Hono { + const routes = new Hono(); + const requireRead = requirePermission(options, CORE_MODULES_UI_READ); + const started = new Set(options.snapshot.startedModuleIds); + const modulesWithSettings = options.snapshot.discoveredModules + .filter((module) => module.manifest.configuration.length > 0) + .map((module) => ({ id: module.manifest.id, name: module.manifest.name })) + .sort((left, right) => compareText(left.name, right.name) || compareText(left.id, right.id)); + + const holds = async ( + principal: EkklesiaPrincipal, + permission: PermissionId, + ): Promise => { + const declaration = options.registry.get(permission); + if (!declaration) { + return false; + } + return ( + await options.evaluator.authorize({ + baseline: declaration.baseline, + permission, + principal, + }) + ).allowed; + }; + + routes.get(DASHBOARD_SETTINGS_PATH, requireRead, async (c) => { + c.header("Cache-Control", "no-store"); + c.header("Vary", "Origin, Cookie"); + const principal = getAuthorizedPrincipal(c); + try { + let owner: boolean | undefined; + const isOwner = async (): Promise => { + owner ??= + principal.kind === "authenticated" && + (await options.platformOwners.isPlatformOwner(principal.userId)); + return owner; + }; + const sections: SettingsSection[] = []; + for (const section of SETTINGS_SECTIONS) { + const access = SECTION_ACCESS[section]; + if (access === "platform-owner" ? await isOwner() : await holds(principal, access)) { + sections.push(section); + } + } + const modules = (await holds(principal, CORE_MODULES_CONFIGURATION_MANAGE)) + ? modulesWithSettings.map((module) => ({ ...module, available: started.has(module.id) })) + : []; + return c.json({ modules, sections } satisfies DashboardSettingsResponse, 200); + } catch { + return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503); + } + }); + + return routes; +} diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts index c597cd7..15c4d70 100644 --- a/apps/api/tests/dashboard-host-routes.test.ts +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -21,6 +21,8 @@ import { type ModuleDatabase, type ModuleLifecycleConfiguration, RESERVED_MODULE_IDS, + RESERVED_SETTINGS_NAMES, + RESERVED_TOP_LEVEL_NAMES, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; @@ -40,7 +42,7 @@ import { DashboardHostConfigurationError, createDashboardHostRoutes, } from "../src/dashboard-host-routes.js"; -import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes"; +import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } from "@ekklesia/shell/dashboard-routes"; import { MODULE_DISPLAY_DESCRIPTOR_PREFIX, MODULE_DISPLAY_PAGE_PREFIX, @@ -391,7 +393,7 @@ describe("the platform's reserved top-level names (ADR 0057)", () => { ]) { expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true); } - expect([...RESERVED_MODULE_IDS].sort()).toEqual([ + expect([...RESERVED_TOP_LEVEL_NAMES].sort()).toEqual([ "account", "api", "assets", @@ -402,9 +404,19 @@ describe("the platform's reserved top-level names (ADR 0057)", () => { it("include every Dashboard platform page the shell interprets", () => { for (const page of DASHBOARD_PLATFORM_PAGES) { - expect(RESERVED_MODULE_IDS, page).toContain(page); + expect(RESERVED_TOP_LEVEL_NAMES, page).toContain(page); } }); + + it("include exactly the Settings sections the shell interprets, so /settings/ never collides (ADR 0061)", () => { + expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]); + for (const section of SETTINGS_SECTIONS) { + expect(isReservedModuleId(section), section).toBe(true); + } + expect(RESERVED_MODULE_IDS).toEqual( + [...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(), + ); + }); }); describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => { diff --git a/apps/api/tests/dashboard-settings-routes.test.ts b/apps/api/tests/dashboard-settings-routes.test.ts new file mode 100644 index 0000000..09749ed --- /dev/null +++ b/apps/api/tests/dashboard-settings-routes.test.ts @@ -0,0 +1,248 @@ +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + type EkklesiaPrincipal, + parseEkklesiaUserId, + parseModuleManifest, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + CORE_DEVICES_MANAGE, + CORE_MODULES_CONFIGURATION_MANAGE, + CORE_MODULES_LIFECYCLE_MANAGE, + createAuthorizationEvaluator, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, + type DiscoveredModule, + EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, + type ModuleLifecycleConfiguration, +} from "@ekklesia/core/modules"; +import { describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; +import { + DASHBOARD_SETTINGS_PATH, + type DashboardSettingsResponse, +} from "../src/dashboard-settings-routes.js"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; +import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; +import { + createInMemoryModuleLifecycleStore, + moduleLifecycleSnapshotFor, +} from "./support/in-memory-module-lifecycle.js"; +import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; +import { readyHealth } from "./support/ready-health.js"; + +/** + * `GET /api/v1/dashboard/settings` (ADR 0061): which Settings destinations the viewer may see, + * evaluated by the server through Core's one authorization evaluator. Presentation hints, never + * authority: every Settings data route keeps its own enforcement. + */ + +const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444"); +const viewer = createAuthenticatedPrincipal(viewerId); + +function discovered( + id: string, + options: { readonly name?: string; readonly settings: boolean }, +): DiscoveredModule { + const manifest = parseModuleManifest({ + capabilities: [], + compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, + configuration: options.settings + ? [ + { + default: "Hello", + description: "A greeting.", + key: "greeting", + maxLength: 40, + type: "string", + }, + ] + : [], + entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, + id, + manifestVersion: 8, + name: options.name ?? `${id} module`, + permissions: [], + version: "0.1.0", + }); + return { + contributions: [], + directory: `/modules/${id}`, + entrypoints: { + server: `/modules/${id}/dist/server/index.js`, + ui: `/modules/${id}/dist/ui/index.js`, + }, + manifest, + manifestPath: `/modules/${id}/ekklesia.module.json`, + packageName: `@test/${id}`, + }; +} + +interface Options { + readonly principal?: EkklesiaPrincipal; + readonly owner?: boolean; + readonly grants?: readonly string[]; + readonly modules?: readonly DiscoveredModule[]; + readonly configuration?: ModuleLifecycleConfiguration; + readonly ownerLookupFails?: boolean; +} + +function build(options: Options = {}) { + const modules = options.modules ?? [ + discovered("signage", { name: "Signage", settings: true }), + discovered("calendar", { name: "Calendar", settings: true }), + discovered("retired", { name: "Archive", settings: true }), + discovered("plain", { name: "Plain", settings: false }), + ]; + const isPlatformOwner = vi.fn(async () => { + if (options.ownerLookupFails) { + throw new Error("database down"); + } + return options.owner === true; + }); + const grants = new Set(options.grants ?? []); + const evaluator = createAuthorizationEvaluator({ + grants: { hasActiveGrant: async ({ permission }) => grants.has(String(permission)) }, + platformOwners: { isPlatformOwner }, + }); + const runtime: EkklesiaIdentityRuntime = { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? viewer), + }; + const store = { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn(), + isPlatformOwner, + listGrantsForUser: vi.fn(), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + } as unknown as AuthorizationStore; + const app = createApiApp({ + authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, + dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), + health: readyHealth(), + moduleConfiguration: emptyModuleConfiguration(), + moduleLifecycle: { + snapshot: moduleLifecycleSnapshotFor( + modules, + options.configuration ?? { modules: [{ enabled: false, moduleId: "retired" }] }, + ), + store: createInMemoryModuleLifecycleStore(), + }, + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, + moduleRoutes: Object.freeze([]), + moduleServices: Object.freeze({ + database: Object.freeze({}) as unknown as ModuleDatabase, + }) as LegacyModuleRuntimeServices, + moduleStorage: emptyModuleStorage(), + moduleUi: createModuleUiPublicationRegistry([]), + runtime, + trustedOrigins: ["https://dashboard.example.test"], + }); + return { app, isPlatformOwner }; +} + +async function settings(options: Options = {}) { + const { app } = build(options); + const response = await app.request(DASHBOARD_SETTINGS_PATH); + const body = + response.status === 200 ? ((await response.json()) as DashboardSettingsResponse) : undefined; + return { body, response }; +} + +describe("GET /api/v1/dashboard/settings", () => { + it("shows a signed-in person with no administrative permission nothing at all", async () => { + const { body, response } = await settings(); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(body).toEqual({ modules: [], sections: [] }); + }); + + it("refuses an anonymous viewer, like the navigation it sits beside", async () => { + const { response } = await settings({ principal: ANONYMOUS_PRINCIPAL }); + + expect(response.status).toBe(403); + }); + + it("shows the platform owner every section, in order, and every module with settings", async () => { + const { body } = await settings({ owner: true }); + + expect(body).toEqual({ + modules: [ + { available: false, id: "retired", name: "Archive" }, + { available: true, id: "calendar", name: "Calendar" }, + { available: true, id: "signage", name: "Signage" }, + ], + sections: ["general", "branding", "displays", "discover", "system"], + }); + }); + + it("shows Displays exactly to whoever may manage devices", async () => { + const { body } = await settings({ grants: [String(CORE_DEVICES_MANAGE)] }); + + expect(body).toEqual({ modules: [], sections: ["displays"] }); + }); + + it("shows Discover — module lifecycle — exactly to whoever may manage which modules run", async () => { + const { body } = await settings({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)] }); + + expect(body).toEqual({ modules: [], sections: ["discover"] }); + }); + + it("lists modules' settings exactly to whoever may configure modules, flagging disabled ones", async () => { + const { body } = await settings({ grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)] }); + + // A module that declares no configuration has no settings page; a disabled module is listed so + // its address can say it is unavailable, but marked so the Dashboard does not navigate to it. + expect(body).toEqual({ + modules: [ + { available: false, id: "retired", name: "Archive" }, + { available: true, id: "calendar", name: "Calendar" }, + { available: true, id: "signage", name: "Signage" }, + ], + sections: [], + }); + }); + + it("keeps General, Branding, and System to the platform owner until each has a permission of its own", async () => { + const { body } = await settings({ + grants: [ + String(CORE_DEVICES_MANAGE), + String(CORE_MODULES_LIFECYCLE_MANAGE), + String(CORE_MODULES_CONFIGURATION_MANAGE), + ], + }); + + expect(body?.sections).toEqual(["displays", "discover"]); + }); + + it("is a 503, never an empty answer, when authorization cannot be evaluated", async () => { + const { response } = await settings({ ownerLookupFails: true }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); + }); + + it("is a hint, not authority: the data behind a section stays refused without its permission", async () => { + const { app } = build({ grants: [] }); + + for (const path of [ + "/api/v1/module-lifecycle", + "/api/v1/module-configuration/signage", + "/api/v1/grants", + ]) { + const response = await app.request(path); + expect(response.status, path).toBe(403); + } + }); +}); diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index 6522721..1546052 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -617,11 +617,11 @@ async function scanModuleDirectory( } if (isReservedModuleId(manifest.id)) { - // A module's Dashboard page is `/` (ADR 0057), in the same URL space as the - // platform's own top-level namespaces. + // A module's id is a stable URL identifier: its page is `/` (ADR 0057) and its + // settings `/settings/` (ADR 0061), beside the platform's own pages in both. issues.push({ code: "MODULE_ID_RESERVED", - message: `Module ID ${manifest.id} is reserved: the platform uses /${manifest.id} for its own pages and routes.`, + message: `Module ID ${manifest.id} is reserved for the platform's own pages and routes.`, moduleDirectory: directoryName, moduleId: manifest.id, path: "id", diff --git a/core/src/modules/reserved-ids.ts b/core/src/modules/reserved-ids.ts index 724b3b2..f5905dd 100644 --- a/core/src/modules/reserved-ids.ts +++ b/core/src/modules/reserved-ids.ts @@ -1,24 +1,23 @@ /** - * Top-level names the platform owns, which therefore can never be a module id (ADR 0057). + * Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061). * - * Every module with a Dashboard UI is addressed at `/`, beside the platform's own - * top-level namespaces in one URL space. A module called `settings` would collide with the - * Dashboard's Settings pages, and one called `api` with the API itself. This is the complete set the - * platform serves today, not a guess at what it might want later: + * A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at + * `/`, and every module with settings at `/settings/`, beside the platform's + * own pages in the same two namespaces. A module called `settings` would collide with the + * Dashboard's Settings, and one called `discover` with Settings → Discover. This is the complete set + * the platform serves today, not a guess at what it might want later. + */ + +/** + * Top-level namespaces (`/`): * * - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts, * display descriptors, and realtime (`/api/…`); - * - `assets` — the Dashboard's and display host's scripts (`/assets/ekklesia-dashboard/…`, - * `/assets/ekklesia-display/…`); + * - `assets` — the Dashboard's and display host's scripts (`/assets/…`); * - `display` — the display host's pages and service worker (`/display/…`); * - `settings` and `account` — the Dashboard's own Settings and account areas. - * - * `apps/api` holds the list to the routes it actually registers, and to the Dashboard namespaces the - * shell interprets, so a new top-level platform route cannot be added without reserving it here. - * Reserving a name is a breaking change for any installation with a module of that id, so the set - * grows only deliberately. */ -export const RESERVED_MODULE_IDS = Object.freeze([ +export const RESERVED_TOP_LEVEL_NAMES = Object.freeze([ "account", "api", "assets", @@ -26,6 +25,30 @@ export const RESERVED_MODULE_IDS = Object.freeze([ "settings", ] as const); +/** + * The platform's own Settings sections (`/settings/`): General, Branding, Displays, Discover + * (finding, installing, and managing modules), and System. Every other `/settings/` is a + * module's settings. Discover's own pages live beneath it (`/settings/discover/…`), so they cannot + * collide with a module and need no reservation of their own. + */ +export const RESERVED_SETTINGS_NAMES = Object.freeze([ + "branding", + "discover", + "displays", + "general", + "system", +] as const); + +/** + * Every reserved name, sorted. `apps/api` holds this list to the routes it actually registers and to + * the pages and Settings sections the shell interprets, so a new platform route or Settings section + * cannot be added without reserving it here. Reserving a name is a breaking change for any + * installation with a module of that id, so the set grows only deliberately. + */ +export const RESERVED_MODULE_IDS = Object.freeze( + [...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(), +); + const reserved: ReadonlySet = new Set(RESERVED_MODULE_IDS); export function isReservedModuleId(moduleId: string): boolean { diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index b91d008..967a593 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -8,6 +8,8 @@ import { ModuleDiscoveryError, type ModuleDiscoveryIssueCode, RESERVED_MODULE_IDS, + RESERVED_SETTINGS_NAMES, + RESERVED_TOP_LEVEL_NAMES, } from "../src/modules/index.js"; interface ModuleFixtureOptions { @@ -277,8 +279,31 @@ describe("module discovery", () => { // Every module with a Dashboard UI is addressed at `/`, so an id equal to a // platform-owned top-level namespace would collide with the API, the display host, a host // script, or a Dashboard page. - expect(RESERVED_MODULE_IDS).toEqual(["account", "api", "assets", "display", "settings"]); - expect(Object.isFrozen(RESERVED_MODULE_IDS)).toBe(true); + expect(RESERVED_TOP_LEVEL_NAMES).toEqual(["account", "api", "assets", "display", "settings"]); + // A module's settings live at `/settings/` (ADR 0061), beside the platform's own + // Settings sections, so their names are reserved too. + expect(RESERVED_SETTINGS_NAMES).toEqual([ + "branding", + "discover", + "displays", + "general", + "system", + ]); + expect(RESERVED_MODULE_IDS).toEqual([ + "account", + "api", + "assets", + "branding", + "discover", + "display", + "displays", + "general", + "settings", + "system", + ]); + for (const list of [RESERVED_MODULE_IDS, RESERVED_TOP_LEVEL_NAMES, RESERVED_SETTINGS_NAMES]) { + expect(Object.isFrozen(list)).toBe(true); + } for (const id of RESERVED_MODULE_IDS) { const modulesDirectory = await createModulesDirectory(); @@ -299,7 +324,17 @@ describe("module discovery", () => { it("accepts ids that merely resemble a reserved name", async () => { const modulesDirectory = await createModulesDirectory(); - for (const id of ["settings-sync", "my-account", "displays", "api-docs"]) { + // `modules` is an ordinary id again: module management is Settings → Discover. + for (const id of [ + "settings-sync", + "my-account", + "display-board", + "api-docs", + "general-info", + "modules", + "discovery", + "systems", + ]) { await createModule(modulesDirectory, id); } @@ -311,9 +346,13 @@ describe("module discovery", () => { expect(modules.map((module) => module.manifest.id).sort()).toEqual([ "api-docs", - "displays", + "discovery", + "display-board", + "general-info", + "modules", "my-account", "settings-sync", + "systems", ]); expect(isReservedModuleId("settings")).toBe(true); expect(isReservedModuleId("settings-sync")).toBe(false); diff --git a/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md b/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md index bf50f61..2c47fc8 100644 --- a/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md +++ b/docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md @@ -221,6 +221,10 @@ verification and reset links still land on `/` with a query (ADR 0038). ## Deferred +> **Cross-reference — see +> [ADR 0061](0061-settings-hierarchy-and-module-settings-routes.md).** The Settings pages and their +> addresses have since been defined; the rest of this list is unchanged. + Each of these needs its own decision. This ADR reserves the addresses for them and decides none of them: diff --git a/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md b/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md new file mode 100644 index 0000000..1bccd2e --- /dev/null +++ b/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md @@ -0,0 +1,177 @@ +# 0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/` + +- Status: Accepted +- Date: 2026-10-15 +- Extends: [0027](0027-dashboard-navigation-and-flat-modules.md), + [0028](0028-module-operational-lifecycle.md), + [0029](0029-module-configuration-and-secrets.md), + [0057](0057-dashboard-urls-are-host-owned-routes.md), + [0058](0058-dashboard-shell-navigation-and-lucide-icons.md), + [0059](0059-shared-interaction-primitives.md) + +## Context + +DESIGN.md makes Settings the one home for shared administration (§9): church-wide configuration in +Settings, personal configuration under the account area (§9.2), every module's settings reachable +through Settings rather than an independent system inside the module (§9.3), and people without +management permission not shown administration they cannot carry out (§9.2). ROADMAP §5.3 asks that +the navigation and ownership model exist before every category is functional. + +ADR 0057 reserved `/settings` and served the Dashboard page throughout it, but defined no page there. +Three things had to be decided before any section could be built: the address of each page, how the +Settings hierarchy sits in the shell's one sidebar (ADR 0058), and how the browser learns which +Settings destinations a person may see — it knew only that they were signed in, plus each module's +own permission hints (ADR 0026). + +## Decision + +### The URL model + +```text +/settings the Settings landing page +/settings/general General: how this installation works for everyone +/settings/branding Branding: the church's name, logo, and colours +/settings/displays Displays: paired screens +/settings/discover enters Discover at Browse (respelled in place, below) +/settings/discover/browse Browse: find and install modules +/settings/discover/installed Installed: see and manage installed modules +/settings/discover/updates Updates: module and platform updates +/settings/system System: version, health, and diagnostics +/settings/ that module's settings +/settings//… reserved for a module's deeper settings pages +``` + +The distinction is the decision: + +- **`/settings/discover/…` is module lifecycle** — finding, installing, turning on and off, inspecting, + updating, and eventually removing modules. +- **`/settings/` is configuring that module.** It is never nested under Discover or under a + `modules` segment: a module's settings are a first-class Settings destination. + +**"Modules" is the product's word**, in the interface as in the architecture — never "features", +"apps", "extensions", or "add-ons". "Discover" is the name of a navigation destination, not a +replacement for it: inside Discover the pages say "Discover modules", "Installed modules", and "Module +updates". Implementation terms — Core, a platform module — never appear. + +**Entering Discover opens Browse, at one address.** `/settings/discover` reads as Browse, whose +canonical spelling is `/settings/discover/browse`; the shell respells it in place (`replace`, no new +history entry), exactly as it corrects a trailing slash (ADR 0057). There is one meaningful Browse +address rather than two, and Discover's own item in the navigation links to it. + +**`/settings` is a real landing page**, never a redirect to the first section: the two groups the +person may open, each item named and described in a line. It is an index, not a dashboard. + +Anything deeper than these addresses is `not-found` in the shell until its page exists. The API already +serves the page throughout `/settings/*`, and the proxies already forward it (ADR 0057), so deeper pages +need no server or deployment change. + +### Module ids cannot collide with a Settings section + +A module id is a **stable URL identifier**: its page is `/` and its settings +`/settings/`. So the platform's own Settings section names join the top-level names in Core's +one list, `RESERVED_MODULE_IDS` (`core/src/modules/reserved-ids.ts`), now in two documented groups: + +| Group | Names | +| --- | --- | +| Top-level namespaces (`/`) | `account`, `api`, `assets`, `display`, `settings` | +| Settings sections (`/settings/`) | `branding`, `discover`, `displays`, `general`, `system` | + +Discovery refuses a module that declares any of them (`MODULE_ID_RESERVED`), at startup, in +`pnpm modules:validate`, and in package verification. Look-alikes stay valid — `general-info`, +`discovery`, `display-board`, `systems` — and so does `modules`, since module management is Discover. +Discover's views live beneath `/settings/discover/`, so they cannot collide with a module and are not +reserved. `apps/api` tests hold Core's Settings group equal to the shell's `SETTINGS_SECTIONS`, so a +section cannot be added to one without the other. + +### Settings sits in the same sidebar + +Settings is one item in the shell's one navigation (ADR 0058), after the modules and above the account +area. While a Settings page is open it expands in place into two labelled groups — **Platform** +(General, Branding, Displays, Discover, System) and **Modules** (each module's settings) — and inside +Discover, Browse, Installed, and Updates beneath it. The rest of the navigation stays where it is, so +leaving Settings is one click on any other item; it is not a second application with its own shell. +The phone drawer shows the identical hierarchy. Exactly one item carries `aria-current="page"`. + +### Which destinations a person sees is evaluated by the server + +`GET /api/v1/dashboard/settings` (`apps/api/src/dashboard-settings-routes.ts`), readable by any signed-in +person (`core.modules.ui.read`, like the navigation beside it), answers which sections and which modules' +settings this person may see, through Core's one authorization evaluator: + +| Destination | Shown to | +| --- | --- | +| Displays | `core.devices.manage` | +| Discover | `core.modules.lifecycle.manage` — deciding which modules run | +| a module's settings | `core.modules.configuration.manage` (ADR 0029), for every module that declares configuration | +| General, Branding, System | the **platform owner** only, until each defines its own permission | + +General, Branding, and System have no permission yet because nothing in them exists to authorize. +Rather than invent three speculative permissions, they are shown only to the platform owner — the one +principal who already holds every permission — and each gains a delegable permission in the work that +builds it. Platform updates in Discover → Updates will likewise get their own permission then. + +A module **has settings** when its manifest declares configuration (ADR 0029). That is the existing, +authoritative signal; no manifest change is needed. A disabled or blocked module with settings is listed +with `available: false`. + +These are **presentation hints, not authority**, as ADR 0026's are. Every route behind a section keeps +its own enforcement, unchanged, and a test proves the data behind each is still refused without its +permission. An evaluation failure is a `503`, never an empty answer that would look like a permissions +problem. The browser decides nothing about permissions itself. + +### What each Settings address shows + +- A section or module the person may see shows its page. +- A section the person may not see, or a module they know of but cannot configure (or that has no + settings), shows *These settings aren't available* — without saying which. +- A disabled module's settings show *This module is turned off*. Never a silent redirect. +- A name that is no module the person knows of is *Page not found*. +- When the Settings view cannot be loaded, the Settings link is withheld and its pages say so, with + Retry; the rest of the Dashboard keeps working. +- When a person can see nothing in Settings, the Settings link is not shown at all. + +### Pages are honest placeholders + +Every section, Discover view, and module's settings page in this decision is a placeholder: Back to +Settings (`PageHeader`, ADR 0059), a title and one-line description, and *Not available yet* with what +will happen there. No fake controls, no dead fields. The work that builds each replaces its placeholder. + +Back goes to the page's parent, Settings. A validated return target (ADR 0059) takes precedence once +something carries one, so `/signage` → `/settings/signage` can return to `/signage`; how a module links +into its settings with a return target is the module navigation contract's to define. + +## Alternatives + +- **`/settings/modules` for module management, and `/settings/modules/` for a module's + settings.** Rejected: it nests a module's configuration under its lifecycle and makes "configure + Signage" two levels deep. Discover is lifecycle; `/settings/` is configuration. +- **`/settings/discover` as a page of its own beside `/settings/discover/browse`.** Rejected: two + meaningful addresses for one view. +- **Redirecting `/settings` to the first section.** Rejected: a landing page says what Settings holds, + and the first section differs per person. +- **Replacing the module navigation with the Settings hierarchy while in Settings.** Rejected: DESIGN.md + keeps one sidebar with Settings in it (§4.2), and leaving Settings should not require a second + navigation model. +- **Probing each section's API for a `403` from the browser.** Rejected: several requests per page view, + and it confuses "may not" with "failed". +- **Defining General, Branding, and System permissions now.** Rejected as speculative; the owner-only + interim rule is deny-by-default with the owner's existing bypass. +- **A manifest field declaring "has settings".** Unnecessary: declared configuration already says so; + module-owned settings pages arrive with the module navigation contract. +- **Reserving Discover's view names.** Unnecessary: they live under `/settings/discover/`. + +## Consequences + +- Every later Settings section has an address, a navigation entry, a permission rule, and a placeholder + to replace. +- A module may not be called `branding`, `discover`, `displays`, `general`, or `system`. No known module + is; one that was would fail discovery and must be renamed. +- The shell asks one more endpoint at bootstrap, alongside the navigation. +- A person without any administrative permission sees no Settings link. + +## Deferred + +The real General, Branding, Displays, Discover (catalog, installation, lifecycle, updates), and System +pages, and each one's permission; module settings screens and their deeper pages; how a module links to +its settings with a validated return target (the module navigation contract, manifest v9); moving the +transitional device panel into Settings → Displays; and the account area. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index d8d1072..6c8cdd2 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -69,3 +69,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted - [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted - [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted +- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 655d445..7b55c50 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -601,7 +601,13 @@ value, and the token stylesheet selects Light, Dark, or the system's scheme — durations when motion is reduced — in CSS alone, so the Dashboard and every module root follow the operating system live; the host chooses an explicit appearance through a small controller, applied before React renders. Contrast is tested for every pairing the elements use, in both schemes -([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). `pnpm boundaries` opens +([ADR 0060](decisions/0060-light-dark-system-theme-and-motion.md)). Settings is one host-owned +hierarchy in the same sidebar: a landing page, the platform's General, Branding, Displays, Discover +(module lifecycle: Browse, Installed, Updates), and System, and each module's settings at +`/settings/`. Which of them a person sees is evaluated by the server +(`GET /api/v1/dashboard/settings`) as presentation hints, while every route behind them keeps its own +enforcement; the Settings section names are reserved module ids +([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). `pnpm boundaries` opens exactly two shared roles to modules **by name**, and pins both design packages beneath everything else. The Dashboard and display install the defaults at their document root; custom properties inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at diff --git a/docs/module-development/README.md b/docs/module-development/README.md index fea3d96..ac1052e 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -540,7 +540,15 @@ Dashboard UI is addressed at `/` — `/calendar` for this one ([ADR 0057](../architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)) — in the same top-level URL space as the platform's own pages, so a few ids are **reserved** and Core refuses a module that declares one (`MODULE_ID_RESERVED`, reported by discovery, `pnpm modules:validate`, and -package verification): `account`, `api`, `assets`, `display`, and `settings`. A provided capability +package verification): `account`, `api`, `assets`, `display`, and `settings` at the top level, and +`branding`, `discover`, `displays`, `general`, and `system`, the platform's own Settings sections. Your +module id is a **stable URL identifier**: if your module declares `configuration`, its settings live at +`/settings/` — `/settings/calendar` for this one — as a first-class Settings destination, +visible to whoever holds `core.modules.configuration.manage` +([ADR 0061](../architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)). +Module lifecycle — installing, turning on and off, updating — is the platform's Settings → Discover, not +yours to build. Do not build a second settings system inside your module; link to your Settings page +instead once the module navigation contract can carry a return link. A provided capability has its own exact semantic version because its public contract can evolve independently from the module release. Capability dependencies use semantic-version ranges. Optional means the provider may be absent; if it is installed, its capability version must be compatible. diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 4c7a904..887633c 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -347,6 +347,80 @@ check( "widening the window brings the sidebar back on the same page", ); +// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer ------- +await navigate(`${dashboardOrigin}/settings`); +check( + await waitFor( + `${selector("settings-home")} && ${selector("select-settings")}?.getAttribute("aria-current") === "page"`, + "the Settings landing page from its own address", + ), + "a deep link to /settings opens the Settings landing page", +); +await evaluate(`${selector("select-settings-displays")}.click()`); +await waitFor(selector("settings-section-displays"), "Settings → Displays"); +await navigate(`${dashboardOrigin}/settings/displays`); +check( + await waitFor( + `location.pathname === "/settings/displays" && ${selector("settings-section-displays")} && ${selector("select-settings-displays")}?.getAttribute("aria-current") === "page"`, + "Settings → Displays after a refresh", + ), + "refreshing a Settings page stays on it, marked in the navigation", +); +await navigate(`${dashboardOrigin}/settings/discover`); +check( + await waitFor( + `location.pathname === "/settings/discover/browse" && ${selector("settings-discover-browse")}`, + "Discover opening at Browse", + ), + "entering Discover opens Browse at its one canonical address", +); +if (openedModuleId) { + await navigate(`${dashboardOrigin}/settings/${openedModuleId}`); + check( + await waitFor( + `${selector("module-settings")} && ${selector(`select-module-settings-${openedModuleId}`)}?.getAttribute("aria-current") === "page"`, + "the module's settings page", + ), + `/settings/${openedModuleId} opens the module's settings`, + ); +} +await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 2, + height: 844, + mobile: true, + width: 390, +}); +await navigate(`${dashboardOrigin}/settings`); +await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger in Settings"); +await evaluate(`${selector("mobile-navigation-trigger")}.click()`); +await waitFor( + `${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`, + "Settings in the phone drawer", +); +await evaluate(`${selector("select-settings-system")}.click()`); +check( + await waitFor( + `location.pathname === "/settings/system" && !${selector("navigation-drawer")} && ${selector("settings-section-system")}`, + "Settings → System chosen from the drawer", + ), + "the phone drawer offers the same Settings hierarchy and closes once a page is chosen", +); +await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 1, + height: 1080, + mobile: false, + width: 1920, +}); +await waitFor(selector("select-overview"), "the sidebar after widening the window"); +await evaluate(`${selector("select-overview")}.click()`); +check( + await waitFor( + `location.pathname === "/" && ${selector("host-overview")} && !${selector("settings-subnavigation")}`, + "the Overview from Settings", + ), + "leaving Settings returns to the normal Dashboard navigation", +); + // --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar --------------- await navigate(`${dashboardOrigin}/`); await waitFor(selector("host-account-menu"), "the account control"); diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index 92a8fe2..f0bd058 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -37,6 +37,15 @@ import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ResetPasswordForm } from "./ResetPasswordForm.js"; import { ShellLayout } from "./ShellLayout.js"; +import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js"; +import { + DiscoverPage, + ModuleSettingsPage, + SettingsHomePage, + SettingsLoadFailedPage, + SettingsSectionPage, + SettingsUnavailablePage, +} from "./SettingsPages.js"; import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js"; import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js"; @@ -167,10 +176,34 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry ]; } +function SettingsPageView(props: { + readonly page: SettingsPage; + readonly history: HostHistory; + readonly onRetry: () => void; +}): ReactElement { + const { page, history } = props; + switch (page.kind) { + case "home": + return ; + case "section": + return ; + case "discover": + return ; + case "module": + return ; + case "unavailable": + return ; + case "load-failed": + return ; + case "not-found": + return ; + } +} + /** A stable identity for "which page is showing", so a non-canonical spelling of the same page is * not a page change. */ function routeKey(route: DashboardRoute): string { - return route.kind === "module" ? `module:${route.moduleId}` : route.kind; + return route.kind === "not-found" ? "not-found" : dashboardPath(route); } function HostMessage(props: { @@ -437,14 +470,28 @@ export function HostApp(props: HostAppProps): ReactElement { state.status === "ready" && route.kind === "module" ? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId) : undefined; + const settingsPage = + state.status === "ready" && + (route.kind === "settings" || + route.kind === "settings-section" || + route.kind === "discover" || + route.kind === "module-settings") + ? resolveSettingsPage( + route, + state.bootstrap.settings, + new Set(allEntries(state.bootstrap).map((entry) => entry.descriptor.id)), + ) + : undefined; const pageTitle = state.status !== "ready" ? undefined - : route.kind === "overview" - ? "Overview" - : route.kind === "module" - ? (openEntry?.name ?? "This page isn’t available") - : "Page not found"; + : settingsPage !== undefined + ? settingsPageTitle(settingsPage) + : route.kind === "overview" + ? "Overview" + : route.kind === "module" + ? (openEntry?.name ?? "This page isn’t available") + : "Page not found"; const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle; useEffect(() => { @@ -566,10 +613,13 @@ export function HostApp(props: HostAppProps): ReactElement { onSignOut={() => void signOut()} pageTitle={pageTitle} route={route} + settings={bootstrap.settings} sidebarPreference={sidebarPreference} viewport={viewport} > - {route.kind === "overview" ? ( + {settingsPage !== undefined ? ( + + ) : route.kind === "overview" ? ( ) : route.kind === "module" ? ( openEntry ? ( diff --git a/surfaces/shell/src/browser/SettingsPages.tsx b/surfaces/shell/src/browser/SettingsPages.tsx new file mode 100644 index 0000000..04e7c50 --- /dev/null +++ b/surfaces/shell/src/browser/SettingsPages.tsx @@ -0,0 +1,248 @@ +import { designToken } from "@ekklesia/design-tokens"; +import { Button, EmptyState, ErrorState, PageHeader, Panel } from "@ekklesia/ui"; +import type { ReactElement } from "react"; +import type { + DashboardDestination, + DiscoverView, + SettingsPageSection, +} from "../dashboard-routes.js"; +import type { DashboardSettings, DashboardSettingsModule } from "../host-protocol.js"; +import { dashboardBack } from "./dashboard-back.js"; +import { DashboardLink } from "./DashboardLink.js"; +import type { HostHistory } from "./host-history.js"; +import { + DISCOVER_VIEW_COPY, + navigableModuleSettings, + SETTINGS_SECTION_COPY, + settingsSectionDestination, +} from "./settings-catalog.js"; +import { ShellIcon, type ShellIconName } from "./shell-icons.js"; + +/** + * The Dashboard's Settings pages (ADR 0061). + * + * This is the foundation only: the landing page, and a page at every Settings address, each honest + * about what exists. No section's real controls exist yet, so each says so plainly rather than + * showing fake fields; the work that builds a section replaces its placeholder. + */ + +const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" }; + +/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings; once the + * module navigation contract carries a return target, a validated one takes precedence. */ +function settingsBack(history: HostHistory, returnTarget?: unknown) { + return dashboardBack(history, { + fallback: BACK_TO_SETTINGS, + label: (destination) => (destination.kind === "settings" ? "Back to Settings" : "Back"), + returnTarget, + }); +} + +function SettingsLinkList(props: { + readonly history: HostHistory; + readonly items: readonly { + readonly key: string; + readonly destination: DashboardDestination; + readonly label: string; + readonly description?: string; + readonly icon: ShellIconName; + readonly testId: string; + }[]; +}): ReactElement { + return ( +
    + {props.items.map((item) => ( +
  • + + + + {item.label} + {item.description === undefined ? undefined : ( + {item.description} + )} + + +
  • + ))} +
+ ); +} + +/** + * `/settings`: a real landing page, not a redirect to the first section — the two groups the + * person may open, each item named and described in a line. Not a dashboard of its own. + */ +export function SettingsHomePage(props: { + readonly history: HostHistory; + readonly settings: DashboardSettings; +}): ReactElement { + const modules = navigableModuleSettings(props.settings); + const empty = props.settings.sections.length === 0 && modules.length === 0; + return ( +
+ + {empty ? ( + + ) : undefined} + {props.settings.sections.length > 0 ? ( + + ({ + description: SETTINGS_SECTION_COPY[section].description, + destination: settingsSectionDestination(section), + icon: SETTINGS_SECTION_COPY[section].icon, + key: section, + label: SETTINGS_SECTION_COPY[section].label, + testId: `settings-home-${section}`, + }))} + /> + + ) : undefined} + {modules.length > 0 ? ( + + ({ + destination: { kind: "module-settings", moduleId: module.id }, + icon: "module", + key: module.id, + label: module.name, + testId: `settings-home-module-${module.id}`, + }))} + /> + + ) : undefined} +
+ ); +} + +const SECTION_NOT_YET: Readonly> = Object.freeze({ + branding: "Your church's name, logo, and colours will be managed here.", + displays: + "Paired screens will be managed here. Until then, pair and revoke displays from Display devices in the navigation.", + general: "Settings that apply across this installation will be managed here.", + system: "Version, health, and diagnostics for this installation will appear here.", +}); + +/** `/settings/discover/`: one of Discover's views, not built yet. Module lifecycle lives + * here; configuring a module is its own `/settings/`. */ +export function DiscoverPage(props: { + readonly history: HostHistory; + readonly view: DiscoverView; +}): ReactElement { + const copy = DISCOVER_VIEW_COPY[props.view]; + return ( +
+ + +
+ ); +} + +/** `/settings/
`: a platform section the person may see, not built yet. */ +export function SettingsSectionPage(props: { + readonly history: HostHistory; + readonly section: SettingsPageSection; +}): ReactElement { + const copy = SETTINGS_SECTION_COPY[props.section]; + return ( +
+ + +
+ ); +} + +/** `/settings/`: a running module's settings, not built yet. */ +export function ModuleSettingsPage(props: { + readonly history: HostHistory; + readonly module: DashboardSettingsModule; +}): ReactElement { + return ( +
+ + +
+ ); +} + +export type SettingsUnavailableReason = "no-access" | "module-disabled"; + +/** + * A Settings address that exists but cannot be opened by this person right now. It never says + * which of "you may not" or "there is nothing here" applies for a module the person cannot + * configure, and never echoes the address. + */ +export function SettingsUnavailablePage(props: { + readonly history: HostHistory; + readonly reason: SettingsUnavailableReason; +}): ReactElement { + const disabled = props.reason === "module-disabled"; + return ( +
+ +
+ ); +} + +/** Settings could not be loaded: the rest of the Dashboard still works, and Retry asks again. */ +export function SettingsLoadFailedPage(props: { readonly onRetry: () => void }): ReactElement { + return ( +
+ Retry} + description="The rest of the Dashboard still works. Try again in a moment." + title="Settings could not be loaded" + /> +
+ ); +} diff --git a/surfaces/shell/src/browser/ShellLayout.tsx b/surfaces/shell/src/browser/ShellLayout.tsx index 12c0ea6..3db0fb3 100644 --- a/surfaces/shell/src/browser/ShellLayout.tsx +++ b/surfaces/shell/src/browser/ShellLayout.tsx @@ -11,8 +11,12 @@ import { useState, useSyncExternalStore, } from "react"; -import type { DashboardRoute } from "../dashboard-routes.js"; -import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js"; +import { type DashboardRoute, dashboardPath } from "../dashboard-routes.js"; +import type { + DashboardNavigation, + DashboardSettings, + EkklesiaHostConfig, +} from "../host-protocol.js"; import { AccountMenu } from "./AccountMenu.js"; import { DevicePanel } from "./DevicePanel.js"; import type { HostHistory } from "./host-history.js"; @@ -40,6 +44,7 @@ export interface ShellLayoutProps { readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; + readonly settings: DashboardSettings | "unavailable"; readonly route: DashboardRoute; readonly viewport: HostViewport; readonly sidebarPreference: SidebarPreference; @@ -83,6 +88,7 @@ function Sidebar(props: { readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; + readonly settings: DashboardSettings | "unavailable"; readonly route: DashboardRoute; readonly mode: SidebarMode; readonly onToggle: () => void; @@ -110,6 +116,7 @@ function Sidebar(props: { navigation={props.navigation} presentation={presentation} route={props.route} + settings={props.settings} /> {/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding the sidebar reveals it. Stage 1 moves it to Settings → Displays. */} @@ -135,6 +142,7 @@ function NavigationDrawer(props: { readonly config: EkklesiaHostConfig; readonly history: HostHistory; readonly navigation: DashboardNavigation; + readonly settings: DashboardSettings | "unavailable"; readonly route: DashboardRoute; readonly onDismiss: () => void; readonly onNavigate: () => void; @@ -213,6 +221,7 @@ function NavigationDrawer(props: { onNavigate={props.onNavigate} presentation={presentation} route={props.route} + settings={props.settings} /> @@ -224,8 +233,10 @@ function NavigationDrawer(props: { ); } +/** Which page is showing, by its canonical address, so moving between two Settings sections or two + * modules is a page change and a respelling of the same page is not. */ function routeKey(route: DashboardRoute): string { - return route.kind === "module" ? `module:${route.moduleId}` : route.kind; + return route.kind === "not-found" ? "not-found" : dashboardPath(route); } /** @@ -321,6 +332,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement { onSignOut={props.onSignOut} onToggle={toggleMode} route={route} + settings={props.settings} /> )}
@@ -371,6 +383,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement { onNavigate={() => closeDrawer("main")} onSignOut={props.onSignOut} route={route} + settings={props.settings} /> ) : undefined}
diff --git a/surfaces/shell/src/browser/ShellNavigation.tsx b/surfaces/shell/src/browser/ShellNavigation.tsx index 8fbd435..f197a27 100644 --- a/surfaces/shell/src/browser/ShellNavigation.tsx +++ b/surfaces/shell/src/browser/ShellNavigation.tsx @@ -1,10 +1,23 @@ import { designToken } from "@ekklesia/design-tokens"; import type { CSSProperties, ReactElement } from "react"; import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js"; -import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js"; +import type { + DashboardNavigation, + DashboardNavigationEntry, + DashboardSettings, +} from "../host-protocol.js"; import { DashboardLink } from "./DashboardLink.js"; import type { HostHistory } from "./host-history.js"; import { useNavTooltip } from "./NavTooltip.js"; +import { DISCOVER_VIEWS } from "../dashboard-routes.js"; +import { + DISCOVER_VIEW_COPY, + hasVisibleSettings, + isSettingsRoute, + navigableModuleSettings, + SETTINGS_SECTION_COPY, + settingsSectionDestination, +} from "./settings-catalog.js"; import { ShellIcon, type ShellIconName } from "./shell-icons.js"; /** @@ -121,10 +134,187 @@ export interface NavigationListProps { readonly route: DashboardRoute; readonly history: HostHistory; readonly presentation: NavigationPresentation; + /** Which Settings destinations this person may see (ADR 0061). */ + readonly settings: DashboardSettings | "unavailable"; /** Called once a destination is chosen, for the drawer to close. */ readonly onNavigate?: () => void; } +/** + * Discover in the Settings navigation: one item that opens Browse, which — while a Discover view is + * open — shows its three views beneath it. The open view carries `aria-current`; Discover itself does + * not, so exactly one item is the current page. + */ +function DiscoverNavigation(props: { + readonly id: string; + readonly route: DashboardRoute; + readonly history: HostHistory; + readonly presentation: NavigationPresentation; + readonly onNavigate?: () => void; +}): ReactElement { + const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {}; + const open = props.route.kind === "discover"; + const copy = SETTINGS_SECTION_COPY.discover; + return ( + <> + + {open ? ( +
  • +
      + {DISCOVER_VIEWS.map((view) => ( + + ))} +
    +
  • + ) : undefined} + + ); +} + +/** + * Settings, in the same sidebar as everything else (DESIGN.md §4.2, ADR 0061): one Settings item + * after the modules, which — while a Settings page is open — expands in place into the platform's + * own sections and each module's settings. The rest of the navigation stays where it was, so leaving + * Settings is one click on any other item, and the hierarchy is identical in the phone drawer. + * + * Only what the server said this person may see is listed, and nothing at all when that is nothing: + * no Settings link leads to a page of things they cannot change. + */ +function SettingsNavigation(props: { + readonly id: string; + readonly settings: DashboardSettings | "unavailable"; + readonly route: DashboardRoute; + readonly history: HostHistory; + readonly presentation: NavigationPresentation; + readonly onNavigate?: () => void; +}): ReactElement | null { + if (!hasVisibleSettings(props.settings) || props.settings === "unavailable") { + return null; + } + const { route, settings } = props; + const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {}; + const open = isSettingsRoute(route.kind); + const modules = navigableModuleSettings(settings); + const collapsed = props.presentation.collapsed; + const listStyle = { listStyle: "none", margin: 0, padding: 0 } as const; + const labelClass = collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"; + return ( +
    + {collapsed ?
    + ); +} + /** * The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer * (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any @@ -203,6 +393,14 @@ export function NavigationList(props: NavigationListProps): ReactElement { />
    ) : undefined} + ); } diff --git a/surfaces/shell/src/browser/settings-catalog.ts b/surfaces/shell/src/browser/settings-catalog.ts new file mode 100644 index 0000000..c280bc2 --- /dev/null +++ b/surfaces/shell/src/browser/settings-catalog.ts @@ -0,0 +1,198 @@ +import type { + DashboardDestination, + DiscoverView, + SettingsPageSection, + SettingsSection, +} from "../dashboard-routes.js"; +import type { DashboardSettings, DashboardSettingsModule } from "../host-protocol.js"; +import type { ShellIconName } from "./shell-icons.js"; + +/** + * How each platform Settings section is named and described to a person (ADR 0061). One table, so + * the navigation, the Settings landing page, and each section's own page say the same thing. Plain + * words — never "Core", "platform module", or another implementation term (DESIGN.md §2.3). + */ +export const SETTINGS_SECTION_COPY: Readonly< + Record< + SettingsSection, + { readonly label: string; readonly description: string; readonly icon: ShellIconName } + > +> = Object.freeze({ + branding: { + description: "Your church's name, logo, and colours.", + icon: "settingsBranding", + label: "Branding", + }, + displays: { + description: "Screens paired with this installation.", + icon: "settingsDisplays", + label: "Displays", + }, + general: { + description: "How this installation works for everyone.", + icon: "settingsGeneral", + label: "General", + }, + discover: { + description: "Find, install, and manage the modules your church uses.", + icon: "settingsDiscover", + label: "Discover", + }, + system: { + description: "Version, health, and diagnostics for this installation.", + icon: "settingsSystem", + label: "System", + }, +}); + +/** + * Discover's views (ADR 0061). "Discover" is where module lifecycle lives in the navigation; inside + * it the pages speak of modules — "Discover modules", "Installed modules", "Module updates" — + * because Modules is the product's word for them. + */ +export const DISCOVER_VIEW_COPY: Readonly< + Record< + DiscoverView, + { + readonly label: string; + readonly title: string; + readonly description: string; + readonly notYet: string; + readonly icon: ShellIconName; + } + > +> = Object.freeze({ + browse: { + description: + "Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.", + icon: "discoverBrowse", + label: "Browse", + notYet: "Finding and installing modules will happen here.", + title: "Discover modules", + }, + installed: { + description: "The modules installed for your church.", + icon: "discoverInstalled", + label: "Installed", + notYet: + "Seeing your installed modules, turning them on or off, inspecting them, and removing them will happen here.", + title: "Installed modules", + }, + updates: { + description: "Updates for your modules and for this installation.", + icon: "discoverUpdates", + label: "Updates", + notYet: "Checking for and applying module and platform updates will happen here.", + title: "Module updates", + }, +}); + +/** Where a platform section's navigation item leads: its page, or — for Discover — Browse. */ +export function settingsSectionDestination(section: SettingsSection): DashboardDestination { + return section === "discover" + ? { kind: "discover", view: "browse" } + : { kind: "settings-section", section }; +} + +/** The module settings a person can open from navigation: running modules only. A disabled + * module's settings are not offered, though its address still says it is unavailable. */ +export function navigableModuleSettings( + settings: DashboardSettings | "unavailable", +): readonly DashboardSettingsModule[] { + return settings === "unavailable" ? [] : settings.modules.filter((module) => module.available); +} + +/** Whether Settings has anything to offer this person. With nothing, the Settings link is not + * shown at all, so nobody is shown administration they cannot carry out (DESIGN.md §9.2). */ +export function hasVisibleSettings(settings: DashboardSettings | "unavailable"): boolean { + return ( + settings !== "unavailable" && + (settings.sections.length > 0 || navigableModuleSettings(settings).length > 0) + ); +} + +export function isSettingsRoute(kind: string): boolean { + return ( + kind === "settings" || + kind === "settings-section" || + kind === "discover" || + kind === "module-settings" + ); +} + +/** What a Settings address shows this person. */ +export type SettingsPage = + | { readonly kind: "home"; readonly settings: DashboardSettings } + | { readonly kind: "section"; readonly section: SettingsPageSection } + | { readonly kind: "discover"; readonly view: DiscoverView } + | { readonly kind: "module"; readonly module: DashboardSettingsModule } + | { readonly kind: "unavailable"; readonly reason: "no-access" | "module-disabled" } + | { readonly kind: "load-failed" } + | { readonly kind: "not-found" }; + +/** + * Decides what a Settings address shows (ADR 0061), from what the server said this person may see: + * + * - a section or module they may see shows its page; + * - a disabled module's settings say it is turned off — never a silent redirect; + * - a section they may not see, or a module they know of but may not configure (or that has no + * settings), says the settings aren't available, without saying which; + * - a name that is no module they know of is not found. + */ +export function resolveSettingsPage( + route: + | { readonly kind: "settings" } + | { readonly kind: "settings-section"; readonly section: SettingsPageSection } + | { readonly kind: "discover"; readonly view: DiscoverView } + | { readonly kind: "module-settings"; readonly moduleId: string }, + settings: DashboardSettings | "unavailable", + knownModuleIds: ReadonlySet, +): SettingsPage { + if (settings === "unavailable") { + return { kind: "load-failed" }; + } + if (route.kind === "settings") { + return { kind: "home", settings }; + } + if (route.kind === "discover") { + return settings.sections.includes("discover") + ? { kind: "discover", view: route.view } + : { kind: "unavailable", reason: "no-access" }; + } + if (route.kind === "settings-section") { + return settings.sections.includes(route.section) + ? { kind: "section", section: route.section } + : { kind: "unavailable", reason: "no-access" }; + } + const module = settings.modules.find((candidate) => candidate.id === route.moduleId); + if (module) { + return module.available + ? { kind: "module", module } + : { kind: "unavailable", reason: "module-disabled" }; + } + return knownModuleIds.has(route.moduleId) + ? { kind: "unavailable", reason: "no-access" } + : { kind: "not-found" }; +} + +/** The browser tab's name for a Settings page. */ +export function settingsPageTitle(page: SettingsPage): string { + switch (page.kind) { + case "home": + return "Settings"; + case "section": + return `${SETTINGS_SECTION_COPY[page.section].label} settings`; + case "discover": + return DISCOVER_VIEW_COPY[page.view].title; + case "module": + return `${page.module.name} settings`; + case "unavailable": + return page.reason === "module-disabled" + ? "This module is turned off" + : "These settings aren't available"; + case "load-failed": + return "Settings could not be loaded"; + case "not-found": + return "Page not found"; + } +} diff --git a/surfaces/shell/src/browser/shell-icons.tsx b/surfaces/shell/src/browser/shell-icons.tsx index adeb3f6..0dca9aa 100644 --- a/surfaces/shell/src/browser/shell-icons.tsx +++ b/surfaces/shell/src/browser/shell-icons.tsx @@ -1,12 +1,21 @@ import { Blocks, CircleUserRound, + Compass, LayoutDashboard, type LucideIcon, LogOut, Menu, + MonitorSmartphone, + Palette, + PackageCheck, PanelLeftClose, PanelLeftOpen, + RefreshCw, + Search, + Server, + Settings, + SlidersHorizontal, X, } from "lucide-react"; import type { ReactElement } from "react"; @@ -27,10 +36,19 @@ export const SHELL_ICONS = Object.freeze({ account: CircleUserRound, closeNavigation: X, collapseSidebar: PanelLeftClose, + discoverBrowse: Search, + discoverInstalled: PackageCheck, + discoverUpdates: RefreshCw, expandSidebar: PanelLeftOpen, module: Blocks, openNavigation: Menu, overview: LayoutDashboard, + settings: Settings, + settingsBranding: Palette, + settingsDisplays: MonitorSmartphone, + settingsDiscover: Compass, + settingsGeneral: SlidersHorizontal, + settingsSystem: Server, signOut: LogOut, } satisfies Record); diff --git a/surfaces/shell/src/browser/shell-styles.ts b/surfaces/shell/src/browser/shell-styles.ts index 15c077f..4dad63c 100644 --- a/surfaces/shell/src/browser/shell-styles.ts +++ b/surfaces/shell/src/browser/shell-styles.ts @@ -105,6 +105,15 @@ export const SHELL_STYLESHEET = ` text-transform: uppercase; } +.ek-shell-nav__nested { + border-inline-start: ${designToken("border.width")} solid ${designToken("border.divider")}; + display: grid; + gap: ${designToken("spacing.xs")}; + margin-block-start: ${designToken("spacing.xs")}; + margin-inline-start: ${designToken("spacing.lg")}; + padding-inline-start: ${designToken("spacing.xs")}; +} + .ek-shell-nav__divider { border-top: ${designToken("border.width")} solid ${designToken("border.divider")}; margin: 0 ${designToken("spacing.xs")}; @@ -134,6 +143,46 @@ export const SHELL_STYLESHEET = ` padding-inline: 0; } +.ek-shell-settings-link { + align-items: center; + border-radius: ${designToken("radius.md")}; + box-sizing: border-box; + color: ${designToken("text.primary")}; + display: flex; + gap: ${designToken("spacing.md")}; + padding: ${designToken("spacing.sm")} ${designToken("spacing.md")}; + text-decoration: none; +} + +.ek-shell-settings-link:hover { + background: ${designToken("state.hover-background")}; +} + +.ek-shell-settings-link:focus-visible { + outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")}; + outline-offset: ${designToken("state.focus-visible-offset")}; +} + +.ek-shell-settings-link svg { + color: ${designToken("text.secondary")}; + flex: 0 0 auto; +} + +.ek-shell-settings-link__copy { + display: grid; + gap: ${designToken("spacing.2xs")}; + min-width: 0; +} + +.ek-shell-settings-link__label { + font-weight: ${designToken("typography.weight-medium")}; +} + +.ek-shell-settings-link__description { + color: ${designToken("text.secondary")}; + font-size: ${designToken("typography.size-sm")}; +} + .ek-shell-tooltip { background: ${designToken("background.raised")}; border: ${designToken("border.width")} solid ${designToken("border.base")}; diff --git a/surfaces/shell/src/dashboard-routes.ts b/surfaces/shell/src/dashboard-routes.ts index 76bd49c..d9dfa68 100644 --- a/surfaces/shell/src/dashboard-routes.ts +++ b/surfaces/shell/src/dashboard-routes.ts @@ -8,10 +8,13 @@ * Module pages and platform pages share one top-level namespace: * * ```text - * / Overview - * /settings[/…] Settings - * /account[/…] the account area - * /[/…] a module's page, and later its own sub-pages + * / Overview + * /settings Settings + * /settings/
    a platform Settings section: general, branding, displays, system + * /settings/discover/ finding, installing, and managing modules: browse, installed, updates + * /settings/ a module's settings (and later its own deeper settings pages) + * /account[/…] the account area + * /[/…] a module's page, and later its own sub-pages * ``` * * The server serves the page at `/` only for a module that is actually installed with a @@ -27,16 +30,60 @@ */ export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const); +/** + * The platform's own Settings sections, in their navigation order (ADR 0061). Every other name under + * `/settings/` is a module's settings, at `/settings/`. Module lifecycle — finding, + * installing, turning on and off, updating — is Discover, at `/settings/discover/…`; configuring a + * module is its own `/settings/`. Each section name is also a reserved module id in Core, + * so no module can take one, and `apps/api` tests hold the two lists together. + */ +export const SETTINGS_SECTIONS = Object.freeze([ + "general", + "branding", + "displays", + "discover", + "system", +] as const); + +export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; + +/** The sections that are one page each. Discover is a group of views instead. */ +export type SettingsPageSection = Exclude; + +/** + * Discover's views, in navigation order: Browse (find and install modules), Installed (see and + * manage installed modules), and Updates (module and platform updates). Browse is where Discover + * opens. They live beneath `/settings/discover/`, so none can collide with a module id. + */ +export const DISCOVER_VIEWS = Object.freeze(["browse", "installed", "updates"] as const); + +export type DiscoverView = (typeof DISCOVER_VIEWS)[number]; + /** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */ export type DashboardRoute = | { readonly kind: "overview" } | { readonly kind: "module"; readonly moduleId: string } + | { readonly kind: "settings" } + | { readonly kind: "settings-section"; readonly section: SettingsPageSection } + | { readonly kind: "discover"; readonly view: DiscoverView } + | { readonly kind: "module-settings"; readonly moduleId: string } | { readonly kind: "not-found" }; /** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */ export type DashboardDestination = Exclude; const platformPages: ReadonlySet = new Set(DASHBOARD_PLATFORM_PAGES); +const settingsSections: ReadonlySet = new Set(SETTINGS_SECTIONS); + +const discoverViews: ReadonlySet = new Set(DISCOVER_VIEWS); + +function isSettingsPageSection(value: string): value is SettingsPageSection { + return value !== "discover" && settingsSections.has(value); +} + +function isDiscoverView(value: string): value is DiscoverView { + return discoverViews.has(value); +} function decodeSegment(segment: string): string | undefined { try { @@ -70,6 +117,9 @@ export function parseDashboardPath(pathname: string): DashboardRoute { } const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1); const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]); + if (first === "settings") { + return parseSettingsPath(meaningful.slice(1)); + } if (first === undefined || platformPages.has(first)) { return { kind: "not-found" }; } @@ -79,11 +129,53 @@ export function parseDashboardPath(pathname: string): DashboardRoute { return { kind: "not-found" }; } +/** + * Reads what follows `/settings`. One more segment is a platform section or, failing that, a + * module's settings. `discover` is entered at Browse: `/settings/discover` reads as Browse, whose + * canonical spelling is `/settings/discover/browse`, so the shell respells it in place and there is + * one Browse address rather than two. Anything deeper is reserved for later Settings pages and is + * `not-found` until they exist, rather than silently showing their parent. + */ +function parseSettingsPath(rest: readonly string[]): DashboardRoute { + if (rest.length === 0) { + return { kind: "settings" }; + } + const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]); + if (name === undefined) { + return { kind: "not-found" }; + } + if (name === "discover") { + if (rest.length === 1) { + return { kind: "discover", view: "browse" }; + } + const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]); + return rest.length === 2 && view !== undefined && isDiscoverView(view) + ? { kind: "discover", view } + : { kind: "not-found" }; + } + if (rest.length !== 1) { + return { kind: "not-found" }; + } + return isSettingsPageSection(name) + ? { kind: "settings-section", section: name } + : { kind: "module-settings", moduleId: name }; +} + /** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its * own path segment. */ export function dashboardPath(route: DashboardDestination): string { - if (route.kind === "overview") { - return "/"; + switch (route.kind) { + case "overview": + return "/"; + case "module": + return `/${encodeURIComponent(route.moduleId)}`; + case "settings": + return "/settings"; + case "settings-section": + return `/settings/${route.section}`; + case "discover": + return `/settings/discover/${route.view}`; + case "module-settings": + return `/settings/${encodeURIComponent(route.moduleId)}`; } - return `/${encodeURIComponent(route.moduleId)}`; } diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts index dd542cc..d99b8a8 100644 --- a/surfaces/shell/src/host-protocol.ts +++ b/surfaces/shell/src/host-protocol.ts @@ -1,3 +1,4 @@ +import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js"; import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; /** @@ -36,6 +37,7 @@ export type HostEventSourceFactory = (url: string, options: HostEventSourceOptio * code for a consumer that does not exist. */ const PRINCIPAL_PATH = "/api/v1/principal"; const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation"; +const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings"; const MODULE_DISPLAYS_PATH = "/api/v1/module-displays"; const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; @@ -861,11 +863,94 @@ export async function bootstrapDisplayHost( }); } +/** One module's settings destination, as the server evaluated it for this viewer (ADR 0061). */ +export interface DashboardSettingsModule { + readonly id: string; + readonly name: string; + /** `false` for a disabled or blocked module: its address says so, and navigation omits it. */ + readonly available: boolean; +} + +/** Which Settings destinations this viewer may see. Presentation hints, never authority. */ +export interface DashboardSettings { + readonly sections: readonly SettingsSection[]; + readonly modules: readonly DashboardSettingsModule[]; +} + +const settingsSectionNames: ReadonlySet = new Set(SETTINGS_SECTIONS); + +/** + * Strict, total validation of the Settings view: exact keys, known section names only and each at + * most once, and module ids and names as bounded strings. Anything else is a malformed response. + */ +export function parseDashboardSettings(value: unknown): DashboardSettings { + if (!hasExactKeys(value, ["modules", "sections"])) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + const { modules, sections } = value; + if (!Array.isArray(sections) || !Array.isArray(modules)) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + if ( + !sections.every( + (section): section is SettingsSection => + typeof section === "string" && settingsSectionNames.has(section), + ) || + new Set(sections).size !== sections.length + ) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + const parsedModules = modules.map((module): DashboardSettingsModule => { + if ( + !hasExactKeys(module, ["available", "id", "name"]) || + typeof module.id !== "string" || + module.id.length === 0 || + module.id.length > 64 || + typeof module.name !== "string" || + module.name.trim().length === 0 || + module.name.length > 120 || + typeof module.available !== "boolean" + ) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ available: module.available, id: module.id, name: module.name }); + }); + if (new Set(parsedModules.map((module) => module.id)).size !== parsedModules.length) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + modules: Object.freeze(parsedModules), + sections: Object.freeze([...sections]), + }); +} + +export async function loadDashboardSettings( + config: EkklesiaHostConfig, + signal?: AbortSignal, +): Promise { + return parseDashboardSettings( + await requestPlatformJson(config, DASHBOARD_SETTINGS_PATH, signal, { + forbidden: "HOST_ACCESS_DENIED", + }), + ); +} + export interface HostBootstrap { readonly principal: ModuleUiPrincipal; readonly navigation: DashboardNavigation; + /** + * The Settings destinations this viewer may see, or `"unavailable"` when they could not be + * loaded. Settings failing never stops the rest of the Dashboard from working: the Settings link + * is withheld and its pages say they could not be loaded. + */ + readonly settings: DashboardSettings | "unavailable"; } +const EMPTY_SETTINGS: DashboardSettings = Object.freeze({ + modules: Object.freeze([]), + sections: Object.freeze([]), +}); + const EMPTY_NAVIGATION: DashboardNavigation = Object.freeze({ groups: Object.freeze([]), ungrouped: Object.freeze([]), @@ -883,7 +968,17 @@ export async function bootstrapHost( ): Promise { const principal = await loadHostPrincipal(config, signal); if (principal.kind === "anonymous") { - return { navigation: EMPTY_NAVIGATION, principal }; + return { navigation: EMPTY_NAVIGATION, principal, settings: EMPTY_SETTINGS }; } - return { navigation: await loadDashboardNavigation(config, signal), principal }; + const [navigation, settings] = await Promise.all([ + loadDashboardNavigation(config, signal), + loadDashboardSettings(config, signal).catch((error: unknown) => { + if (signal?.aborted) { + throw error; + } + console.error("[ekklesia] settings navigation unavailable", error); + return "unavailable" as const; + }), + ]); + return { navigation, principal, settings }; } diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts index 112d7a9..9bc62ef 100644 --- a/surfaces/shell/src/index.ts +++ b/surfaces/shell/src/index.ts @@ -13,6 +13,8 @@ export { bootstrapHost, bootstrapDisplayHost, type DashboardNavigation, + type DashboardSettings, + type DashboardSettingsModule, type DashboardNavigationEntry, type DashboardNavigationGroup, type DisplayHostBootstrap, @@ -25,10 +27,12 @@ export { HostProtocolError, type HostProtocolErrorCode, loadDashboardNavigation, + loadDashboardSettings, loadHostPrincipal, loadModuleDisplay, moduleDisplayDescriptorPath, parseDashboardNavigation, + parseDashboardSettings, parseDisplayRoute, parseHostModuleDisplay, parseHostPrincipal, diff --git a/surfaces/shell/tests/dashboard-routes.test.ts b/surfaces/shell/tests/dashboard-routes.test.ts index 9edc18e..f9a9b9c 100644 --- a/surfaces/shell/tests/dashboard-routes.test.ts +++ b/surfaces/shell/tests/dashboard-routes.test.ts @@ -7,6 +7,8 @@ import { DASHBOARD_PLATFORM_PAGES, dashboardPath, parseDashboardPath, + DISCOVER_VIEWS, + SETTINGS_SECTIONS, } from "../src/dashboard-routes.js"; const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); @@ -34,7 +36,8 @@ describe("parseDashboardPath", () => { }); it("never reads a platform page as a module page", () => { - for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) { + expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" }); + for (const pathname of ["/account", "/account/appearance"]) { expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" }); } }); @@ -49,7 +52,10 @@ describe("parseDashboardPath", () => { "/signage/timelines", "/signage/timelines/abc", "/%E0%A4%A", + // Deeper Settings pages are reserved for later (ADR 0061). "/settings/displays/add", + "/settings/signage/timelines", + "/settings//signage", "signage", ]) { expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" }); @@ -57,6 +63,78 @@ describe("parseDashboardPath", () => { }); }); +describe("Settings routes (ADR 0061)", () => { + it("names exactly the platform's own Settings sections, frozen", () => { + expect(Object.isFrozen(SETTINGS_SECTIONS)).toBe(true); + expect(SETTINGS_SECTIONS).toEqual(["general", "branding", "displays", "discover", "system"]); + expect(Object.isFrozen(DISCOVER_VIEWS)).toBe(true); + expect(DISCOVER_VIEWS).toEqual(["browse", "installed", "updates"]); + }); + + it("has a landing page of its own, tolerating one trailing slash", () => { + expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" }); + expect(parseDashboardPath("/settings/")).toEqual({ kind: "settings" }); + }); + + it("reads each platform section, and nothing else, as a platform section", () => { + for (const section of ["general", "branding", "displays", "system"] as const) { + expect(parseDashboardPath(`/settings/${section}`), section).toEqual({ + kind: "settings-section", + section, + }); + } + }); + + it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => { + for (const view of DISCOVER_VIEWS) { + expect(parseDashboardPath(`/settings/discover/${view}`), view).toEqual({ + kind: "discover", + view, + }); + } + // `/settings/discover` means Browse, and the shell respells it `/settings/discover/browse` in + // place, so there is one meaningful Browse address rather than two. + expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" }); + expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover/browse"); + for (const pathname of [ + "/settings/discover/catalog", + "/settings/discover/browse/signage", + "/settings/discover//", + ]) { + expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" }); + } + }); + + it("reads every other name as a module's settings — never under /settings/modules/", () => { + expect(parseDashboardPath("/settings/signage")).toEqual({ + kind: "module-settings", + moduleId: "signage", + }); + expect(parseDashboardPath("/settings/sign%61ge/")).toEqual({ + kind: "module-settings", + moduleId: "signage", + }); + expect(parseDashboardPath("/settings/modules/signage")).toEqual({ kind: "not-found" }); + // `modules` is no longer a platform section, so it is simply a module id like any other. + expect(parseDashboardPath("/settings/modules")).toEqual({ + kind: "module-settings", + moduleId: "modules", + }); + }); + + it("spells each Settings destination canonically and round-trips it", () => { + for (const [route, path] of [ + [{ kind: "settings" }, "/settings"], + [{ kind: "settings-section", section: "displays" }, "/settings/displays"], + [{ kind: "discover", view: "installed" }, "/settings/discover/installed"], + [{ kind: "module-settings", moduleId: "signage" }, "/settings/signage"], + ] as const) { + expect(dashboardPath(route)).toBe(path); + expect(parseDashboardPath(path)).toEqual(route); + } + }); +}); + describe("dashboardPath", () => { it("spells each route canonically, with no implementation prefix", () => { expect(dashboardPath({ kind: "overview" })).toBe("/"); diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index 95b3958..8d4e1bb 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -482,6 +482,8 @@ describe("Ekklesia shell — browser authentication", () => { "/api/auth/sign-in/email", "/api/v1/principal", "/api/v1/dashboard/navigation", + // Which Settings destinations the person may see, asked alongside the navigation (ADR 0061). + "/api/v1/dashboard/settings", // The host-owned device panel loads with the rest of the shell for an administrator. "/api/v1/devices", ]); diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index 434f179..6cd691d 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest"; import { bootstrapHost, type HostModuleUiDescriptor, + HostProtocolError, loadDashboardNavigation, loadHostPrincipal, parseDashboardNavigation, + parseDashboardSettings, parseHostPrincipal, resolveApiOrigin, } from "../src/host-protocol.js"; @@ -293,9 +295,14 @@ describe("host protocol — Dashboard navigation", () => { }); describe("host protocol — bootstrap", () => { - it("resolves the principal and then the Dashboard navigation", async () => { + it("resolves the principal and then the Dashboard navigation and Settings together", async () => { const { calls, implementation } = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), + "/api/v1/dashboard/settings": () => + jsonResponse({ + modules: [{ available: true, id: "example", name: "Example" }], + sections: ["displays"], + }), "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), }); @@ -304,9 +311,57 @@ describe("host protocol — bootstrap", () => { expect(calls.map((call) => call.url)).toEqual([ `${apiBaseUrl}/api/v1/principal`, `${apiBaseUrl}/api/v1/dashboard/navigation`, + `${apiBaseUrl}/api/v1/dashboard/settings`, ]); expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor); + expect(bootstrap.settings).toEqual({ + modules: [{ available: true, id: "example", name: "Example" }], + sections: ["displays"], + }); + }); + + it("keeps the Dashboard working when Settings cannot be loaded", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { implementation } = stubFetch({ + "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), + "/api/v1/dashboard/settings": () => jsonResponse({ code: "UNAVAILABLE" }, 503), + "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), + }); + + const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); + + expect(bootstrap.settings).toBe("unavailable"); + expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor); + consoleError.mockRestore(); + }); + + it("parses the Settings view strictly", () => { + expect(parseDashboardSettings({ modules: [], sections: ["general", "system"] })).toEqual({ + modules: [], + sections: ["general", "system"], + }); + for (const malformed of [ + null, + { sections: [] }, + { modules: [], sections: ["billing"] }, + { modules: [], sections: ["general", "general"] }, + { extra: 1, modules: [], sections: [] }, + { modules: [{ available: "yes", id: "a", name: "A" }], sections: [] }, + { modules: [{ available: true, id: "", name: "A" }], sections: [] }, + { modules: [{ available: true, id: "a", name: " " }], sections: [] }, + { + modules: [ + { available: true, id: "a", name: "A" }, + { available: false, id: "a", name: "B" }, + ], + sections: [], + }, + ]) { + expect(() => parseDashboardSettings(malformed), JSON.stringify(malformed)).toThrow( + HostProtocolError, + ); + } }); it("never requests navigation for an anonymous viewer", async () => { diff --git a/surfaces/shell/tests/host-routing.test.tsx b/surfaces/shell/tests/host-routing.test.tsx index 310109d..42d656f 100644 --- a/surfaces/shell/tests/host-routing.test.tsx +++ b/surfaces/shell/tests/host-routing.test.tsx @@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => { }); it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => { - for (const path of ["/settings/displays", "/calendar/events", "/account"]) { + for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) { await act(async () => { root.unmount(); }); @@ -216,7 +216,10 @@ describe("the Dashboard opens on the page its URL names", () => { // one could not turn `/settings` into a module page. const { imported } = await render("/settings"); - expect(query("host-not-found")).not.toBeNull(); + // `/settings` is the Settings area (ADR 0061) — here its view could not be loaded, since this + // stub answers nothing for it — and never a module page. + expect(query("settings-load-failed")).not.toBeNull(); + expect(query("module-container")).toBeNull(); expect(imported).toEqual([]); expect(query("select-signage")?.getAttribute("href")).toBe("/signage"); expect(container.innerHTML).not.toContain("/modules/"); diff --git a/surfaces/shell/tests/return-target.test.ts b/surfaces/shell/tests/return-target.test.ts index 9d8b24d..7d14565 100644 --- a/surfaces/shell/tests/return-target.test.ts +++ b/surfaces/shell/tests/return-target.test.ts @@ -9,6 +9,12 @@ describe("parseReturnTarget", () => { expect(parseReturnTarget("/")).toEqual({ kind: "overview" }); expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" }); expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" }); + // Settings pages are Dashboard pages too (ADR 0061). + expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" }); + expect(parseReturnTarget("/settings/signage")).toEqual({ + kind: "module-settings", + moduleId: "signage", + }); }); it("refuses anything that is not a string, or is empty or enormous", () => { @@ -54,7 +60,7 @@ describe("parseReturnTarget", () => { "/signage#top", "/api/v1/principal", "/display/signage", - "/settings", + "/settings/displays/add", "/signage/timelines", ]) { expect(parseReturnTarget(value), value).toBeUndefined(); diff --git a/surfaces/shell/tests/settings-navigation.test.tsx b/surfaces/shell/tests/settings-navigation.test.tsx new file mode 100644 index 0000000..6df41b5 --- /dev/null +++ b/surfaces/shell/tests/settings-navigation.test.tsx @@ -0,0 +1,437 @@ +import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostApp } from "../src/browser/HostApp.js"; +import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; +import { createMemoryHostViewport } from "../src/browser/host-viewport.js"; +import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js"; +import type { DashboardSettings } from "../src/host-protocol.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +/** + * The Settings shell (ADR 0061): its landing page, the platform sections and Discover, each + * module's settings, and which of them the server said this person may see. + */ + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; +let consoleError: ReturnType; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + consoleError.mockRestore(); +}); + +function entry(id: string, name: string) { + return { + module: { + api: { baseUrl: `/api/v1/modules/${id}` }, + contributions: [], + display: null, + id, + permissions: [], + realtime: { baseUrl: `/api/v1/module-realtime/${id}` }, + ui: { url: `/api/v1/module-ui/${id}/0.1.0` }, + version: "0.1.0", + }, + name, + }; +} + +const NAVIGATION = { + groups: [], + ungrouped: [entry("signage", "Signage"), entry("calendar", "Calendar")], +}; + +/** An administrator who may see everything: every platform section, two modules' settings, and a + * disabled module (`archive`) whose settings exist but are not navigable. Calendar declares no + * settings, so it is a known module with no settings page. */ +const ADMINISTRATOR: DashboardSettings = { + modules: [ + { available: false, id: "archive", name: "Archive" }, + { available: true, id: "signage", name: "Signage" }, + ], + sections: ["general", "branding", "displays", "discover", "system"], +}; + +interface Rendered { + readonly history: MemoryHostHistory; + readonly calls: string[]; + readonly titles: string[]; +} + +async function render( + path: string, + options: { + readonly settings?: DashboardSettings | "fails"; + readonly compact?: boolean; + readonly history?: MemoryHostHistory; + } = {}, +): Promise { + const history = options.history ?? createMemoryHostHistory(path); + const calls: string[] = []; + const titles: string[] = []; + let settingsAttempts = 0; + const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(new URL(url).pathname); + if (url.endsWith("/api/v1/principal")) { + return Response.json({ kind: "authenticated", userId: "user-1" }); + } + if (url.endsWith("/api/v1/dashboard/navigation")) { + return Response.json(NAVIGATION); + } + if (url.endsWith("/api/v1/dashboard/settings")) { + settingsAttempts += 1; + const settings = options.settings ?? ADMINISTRATOR; + return settings === "fails" && settingsAttempts === 1 + ? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 }) + : Response.json(settings === "fails" ? ADMINISTRATOR : settings); + } + return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); + }) as unknown as typeof fetch; + await act(async () => { + root.render( + ({ + default: { + abi: MODULE_UI_ABI_VERSION, + id: "signage", + mount(element: Element, context: ModuleUiHostContext) { + element.textContent = `${context.moduleId} mounted`; + }, + version: "0.1.0", + }, + })} + setDocumentTitle={(title) => titles.push(title)} + sidebarPreference={createMemorySidebarPreference()} + viewport={createMemoryHostViewport(options.compact ?? false)} + />, + ); + }); + return { calls, history, titles }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +function required(testId: string): HTMLElement { + const element = query(testId); + if (!element) { + throw new Error(`missing ${testId}`); + } + return element; +} + +async function click(element: Element): Promise { + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true })); + }); +} + +const current = () => + [...container.querySelectorAll('nav [aria-current="page"]')].map( + (element) => (element as HTMLElement).dataset.testid, + ); + +describe("the Settings landing page", () => { + it("is a real page, not a redirect, listing what this person may open", async () => { + const { history, titles } = await render("/settings"); + + expect(history.entries).toEqual(["/settings"]); + expect(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings"); + expect( + [...container.querySelectorAll('[data-testid^="settings-home-"]')].map((link) => + link.getAttribute("href"), + ), + ).toEqual([ + "/settings/general", + "/settings/branding", + "/settings/displays", + "/settings/discover/browse", + "/settings/system", + "/settings/signage", + ]); + // A disabled module's settings are not offered. + expect(query("settings-home-module-archive")).toBeNull(); + // Each group is a section of the page, below its one h1. + expect( + [...container.querySelectorAll("main h2")].map((heading) => heading.textContent), + ).toEqual(["Platform", "Modules"]); + expect(titles.at(-1)).toBe("Settings"); + expect(current()).toEqual(["select-settings"]); + }); + + it("says plainly when there is nothing for this person to change", async () => { + await render("/settings", { settings: { modules: [], sections: [] } }); + + expect(query("settings-home")?.textContent).toContain( + "There are no settings for you to change", + ); + }); +}); + +describe("the platform sections", () => { + it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => { + for (const section of ["general", "branding", "displays", "system"] as const) { + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + const { titles } = await render(`/settings/${section}`); + + const page = required(`settings-section-${section}`); + expect(page.querySelector("h1")?.textContent, section).toBeTruthy(); + expect(page.textContent, section).toContain("Not available yet"); + // No fake controls. + expect(page.querySelector("input, select, textarea, form"), section).toBeNull(); + const back = page.querySelector(".ek-page-header__back"); + expect(back?.getAttribute("href"), section).toBe("/settings"); + expect(back?.textContent, section).toContain("Back to Settings"); + expect(current(), section).toEqual([`select-settings-${section}`]); + expect(titles.at(-1), section).toMatch(/ settings$/u); + } + }); + + it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => { + const { history, titles } = await render("/settings/discover"); + + expect(history.entries).toEqual(["/settings/discover/browse"]); + expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe( + "Discover modules", + ); + expect(query("settings-discover-browse")?.textContent).toContain( + "Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.", + ); + expect(titles.at(-1)).toBe("Discover modules"); + expect(current()).toEqual(["select-discover-browse"]); + expect(query("select-settings-discover")?.getAttribute("href")).toBe( + "/settings/discover/browse", + ); + }); + + it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => { + const { history } = await render("/settings/discover/browse"); + + const views = [...required("discover-subnavigation").querySelectorAll("a")]; + expect(views.map((view) => view.textContent)).toEqual(["Browse", "Installed", "Updates"]); + expect(views.map((view) => view.getAttribute("href"))).toEqual([ + "/settings/discover/browse", + "/settings/discover/installed", + "/settings/discover/updates", + ]); + + await click(required("select-discover-installed")); + expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe( + "Installed modules", + ); + await click(required("select-discover-updates")); + expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe( + "Module updates", + ); + expect(history.entries).toEqual([ + "/settings/discover/browse", + "/settings/discover/installed", + "/settings/discover/updates", + ]); + }); + + it("says a section is unavailable to someone who may not see it, rather than showing it", async () => { + await render("/settings/discover/installed", { + settings: { modules: [], sections: ["displays"] }, + }); + + expect(query("settings-no-access")).not.toBeNull(); + expect(query("settings-discover-installed")).toBeNull(); + expect(query("select-settings-discover")).toBeNull(); + }); +}); + +describe("module settings", () => { + it("opens a running module's settings at /settings/, as a placeholder", async () => { + const { titles } = await render("/settings/signage"); + + const page = required("module-settings"); + expect(page.querySelector("h1")?.textContent).toBe("Signage"); + expect(page.textContent).toContain("Not available yet"); + expect(page.querySelector("input, select, textarea, form")).toBeNull(); + expect(titles.at(-1)).toBe("Signage settings"); + expect(current()).toEqual(["select-module-settings-signage"]); + }); + + it("says a disabled module's settings are turned off, rather than redirecting", async () => { + const { history } = await render("/settings/archive"); + + expect(query("settings-module-unavailable")?.textContent).toContain( + "This module is turned off", + ); + expect(history.entries).toEqual(["/settings/archive"]); + expect(query("select-module-settings-archive")).toBeNull(); + }); + + it("says a known module's settings aren't available when there are none for this person", async () => { + await render("/settings/calendar"); + + expect(query("settings-no-access")).not.toBeNull(); + }); + + it("is not found for a name that is no module", async () => { + for (const path of ["/settings/definitely-not-a-module", "/settings/modules"]) { + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + await render(path); + expect(query("host-not-found"), path).not.toBeNull(); + } + }); +}); + +describe("Settings in the navigation", () => { + it("is one item after the modules, expanding into Platform and Modules only inside Settings", async () => { + const { history } = await render("/signage"); + + const settingsLink = required("select-settings"); + expect(settingsLink.getAttribute("href")).toBe("/settings"); + expect(query("settings-subnavigation")).toBeNull(); + const links = [...container.querySelectorAll("nav a")].map((link) => link.textContent); + expect(links).toEqual(["Overview", "Signage", "Calendar", "Settings"]); + + await click(settingsLink); + + expect(history.entries).toEqual(["/signage", "/settings"]); + const sub = required("settings-subnavigation"); + expect([...sub.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([ + "Platform", + "Modules", + ]); + expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "General", + "Branding", + "Displays", + "Discover", + "System", + "Signage", + ]); + // The rest of the navigation stays, so leaving Settings is one click. + await click(required("select-signage")); + expect(query("settings-subnavigation")).toBeNull(); + expect(history.entries).toEqual(["/signage", "/settings", "/signage"]); + }); + + it("shows only the sections and module settings this person may see", async () => { + await render("/settings", { + settings: { + modules: [{ available: true, id: "signage", name: "Signage" }], + sections: ["displays"], + }, + }); + + const sub = required("settings-subnavigation"); + expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "Displays", + "Signage", + ]); + }); + + it("is not offered at all to someone with nothing to change", async () => { + await render("/", { settings: { modules: [], sections: [] } }); + + expect(query("select-settings")).toBeNull(); + }); + + it("is withheld when Settings cannot be loaded, whose pages say so and retry", async () => { + const { calls } = await render("/settings", { settings: "fails" }); + + expect(query("select-settings")).toBeNull(); + expect(query("settings-load-failed")).not.toBeNull(); + // The rest of the Dashboard still works. + expect(query("select-signage")).not.toBeNull(); + + const retry = [...required("settings-load-failed").querySelectorAll("button")].find( + (button) => button.textContent === "Retry", + ) as HTMLButtonElement; + await act(async () => { + retry.click(); + }); + expect(query("settings-home")).not.toBeNull(); + expect(calls.filter((path) => path === "/api/v1/dashboard/settings")).toHaveLength(2); + }); + + it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => { + const history = createMemoryHostHistory("/settings"); + await render("/settings", { history }); + await click(required("select-settings-displays")); + await click(required("select-module-settings-signage")); + + await act(async () => { + history.go(-1); + }); + expect(query("settings-section-displays")).not.toBeNull(); + await act(async () => { + history.go(1); + }); + expect(query("module-settings")).not.toBeNull(); + + // A refresh is the same address, rendered from scratch. + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + await render("/settings/signage", { history: createMemoryHostHistory(history.pathname()) }); + expect(query("module-settings")).not.toBeNull(); + expect(current()).toEqual(["select-module-settings-signage"]); + }); + + it("moves focus to the page when moving between two Settings pages", async () => { + await render("/settings/general"); + const main = required("host-main-region"); + + await click(required("select-settings-branding")); + + expect(document.activeElement).toBe(main); + expect(main.getAttribute("aria-label")).toBe("Branding settings"); + }); + + it("uses the same hierarchy in the phone drawer, which closes once a page is chosen", async () => { + const { history } = await render("/settings", { compact: true }); + await click(required("mobile-navigation-trigger")); + + const drawer = required("navigation-drawer"); + expect( + [...drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')].map( + (link) => link.textContent, + ), + ).toEqual(["General", "Branding", "Displays", "Discover", "System", "Signage"]); + for (const link of drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')) { + expect((link as HTMLElement).style.minHeight).toBe("var(--ekklesia-control-height-lg)"); + } + + await click(required("select-settings-system")); + + expect(query("navigation-drawer")).toBeNull(); + expect(history.entries).toEqual(["/settings", "/settings/system"]); + expect(query("settings-section-system")).not.toBeNull(); + }); +});