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(); + }); +});