Merge pull request 'feat(shell): Settings hierarchy, Discover, and /settings/<module-id> (Stage 1, ADR 0061)' (#108) from feat/settings-shell into main
CI / verify (push) Successful in 1m14s
CI / infrastructure-smoke (push) Successful in 43s
CI / package-release (push) Successful in 51s
CI / image-build (push) Successful in 34s
CI / migration-smoke (push) Successful in 2m19s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / package-publication (push) Successful in 58s
CI / deploy-smoke (split) (push) Successful in 2m45s

Reviewed-on: #108
Reviewed-by: Zayin_Admin <[email protected]>
This commit was merged in pull request #108.
This commit is contained in:
2026-10-15 03:43:00 +00:00
30 changed files with 2343 additions and 50 deletions
+8
View File
@@ -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/<module-id>`. 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.
+14
View File
@@ -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`.
+133
View File
@@ -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/<module-id>` 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<Record<SettingsSection, PermissionId | "platform-owner">> =
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<boolean> => {
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<boolean> => {
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;
}
+15 -3
View File
@@ -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/<module-id> 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)", () => {
@@ -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);
}
});
});
+3 -3
View File
@@ -617,11 +617,11 @@ async function scanModuleDirectory(
}
if (isReservedModuleId(manifest.id)) {
// A module's Dashboard page is `/<module-id>` (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 `/<module-id>` (ADR 0057) and its
// settings `/settings/<module-id>` (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",
+36 -13
View File
@@ -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 `/<module-id>`, 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
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, 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 (`/<name>`):
*
* - `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/<name>`): General, Branding, Displays, Discover
* (finding, installing, and managing modules), and System. Every other `/settings/<name>` 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<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
+43 -4
View File
@@ -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 `/<module-id>`, 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/<module-id>` (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);
@@ -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:
@@ -0,0 +1,177 @@
# 0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`
- 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/<module-id> that module's settings
/settings/<module-id>/… 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/<module-id>` 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 `/<module-id>` and its settings
`/settings/<module-id>`. 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 (`/<name>`) | `account`, `api`, `assets`, `display`, `settings` |
| Settings sections (`/settings/<name>`) | `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/<module-id>` 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/<module-id>` 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.
+1
View File
@@ -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/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted
+7 -1
View File
@@ -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/<module-id>`. 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
+9 -1
View File
@@ -540,7 +540,15 @@ Dashboard UI is addressed at `/<module-id>` — `/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/<module-id>` — `/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.
+74
View File
@@ -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");
+52 -2
View File
@@ -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 <SettingsHomePage history={history} settings={page.settings} />;
case "section":
return <SettingsSectionPage history={history} section={page.section} />;
case "discover":
return <DiscoverPage history={history} view={page.view} />;
case "module":
return <ModuleSettingsPage history={history} module={page.module} />;
case "unavailable":
return <SettingsUnavailablePage history={history} reason={page.reason} />;
case "load-failed":
return <SettingsLoadFailedPage onRetry={props.onRetry} />;
case "not-found":
return <NotFoundPage history={history} />;
}
}
/** 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,9 +470,23 @@ 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
: settingsPage !== undefined
? settingsPageTitle(settingsPage)
: route.kind === "overview"
? "Overview"
: route.kind === "module"
@@ -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 ? (
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
) : route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
@@ -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 (
<ul
style={{
display: "grid",
gap: designToken("spacing.xs"),
listStyle: "none",
margin: 0,
padding: 0,
}}
>
{props.items.map((item) => (
<li key={item.key}>
<DashboardLink
className="ek-shell-settings-link"
data-testid={item.testId}
history={props.history}
style={{ minHeight: designToken("control.height-lg") }}
to={item.destination}
>
<ShellIcon name={item.icon} />
<span className="ek-shell-settings-link__copy">
<span className="ek-shell-settings-link__label">{item.label}</span>
{item.description === undefined ? undefined : (
<span className="ek-shell-settings-link__description">{item.description}</span>
)}
</span>
</DashboardLink>
</li>
))}
</ul>
);
}
/**
* `/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 (
<div data-testid="settings-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader description="How this installation and its modules work." title="Settings" />
{empty ? (
<EmptyState
description="Ask an administrator if you need to change how something works."
title="There are no settings for you to change"
/>
) : undefined}
{props.settings.sections.length > 0 ? (
<Panel title="Platform">
<SettingsLinkList
history={props.history}
items={props.settings.sections.map((section) => ({
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}`,
}))}
/>
</Panel>
) : undefined}
{modules.length > 0 ? (
<Panel title="Modules">
<SettingsLinkList
history={props.history}
items={modules.map((module) => ({
destination: { kind: "module-settings", moduleId: module.id },
icon: "module",
key: module.id,
label: module.name,
testId: `settings-home-module-${module.id}`,
}))}
/>
</Panel>
) : undefined}
</div>
);
}
const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = 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/<view>`: one of Discover's views, not built yet. Module lifecycle lives
* here; configuring a module is its own `/settings/<module-id>`. */
export function DiscoverPage(props: {
readonly history: HostHistory;
readonly view: DiscoverView;
}): ReactElement {
const copy = DISCOVER_VIEW_COPY[props.view];
return (
<div
data-testid={`settings-discover-${props.view}`}
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
back={settingsBack(props.history)}
description={copy.description}
eyebrow="Discover"
title={copy.title}
/>
<EmptyState description={copy.notYet} title="Not available yet" />
</div>
);
}
/** `/settings/<section>`: 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 (
<div
data-testid={`settings-section-${props.section}`}
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
back={settingsBack(props.history)}
description={copy.description}
title={copy.label}
/>
<EmptyState description={SECTION_NOT_YET[props.section]} title="Not available yet" />
</div>
);
}
/** `/settings/<module-id>`: a running module's settings, not built yet. */
export function ModuleSettingsPage(props: {
readonly history: HostHistory;
readonly module: DashboardSettingsModule;
}): ReactElement {
return (
<div data-testid="module-settings" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader
back={settingsBack(props.history)}
description={`How ${props.module.name} works for your church.`}
eyebrow="Settings"
title={props.module.name}
/>
<EmptyState
description={`${props.module.name}'s settings will be changed here.`}
title="Not available yet"
/>
</div>
);
}
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 (
<div data-testid={disabled ? "settings-module-unavailable" : "settings-no-access"}>
<PageHeader
back={settingsBack(props.history)}
description={
disabled
? "The module these settings belong to is turned off. Its settings come back when it is turned on again."
: "These settings aren't available to you. If you need to change them, ask an administrator."
}
title={disabled ? "This module is turned off" : "These settings aren't available"}
/>
</div>
);
}
/** 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 (
<div data-testid="settings-load-failed">
<ErrorState
actions={<Button onClick={props.onRetry}>Retry</Button>}
description="The rest of the Dashboard still works. Try again in a moment."
title="Settings could not be loaded"
/>
</div>
);
}
+16 -3
View File
@@ -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}
/>
<DevicePanel config={props.config} />
</div>
@@ -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}
/>
)}
<div style={{ minWidth: 0 }}>
@@ -371,6 +383,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
onNavigate={() => closeDrawer("main")}
onSignOut={props.onSignOut}
route={route}
settings={props.settings}
/>
) : undefined}
</div>
+199 -1
View File
@@ -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 (
<>
<NavigationItem
current={false}
destination={settingsSectionDestination("discover")}
history={props.history}
icon={copy.icon}
label={copy.label}
{...onNavigate}
presentation={props.presentation}
testId="select-settings-discover"
/>
{open ? (
<li>
<ul
aria-label={copy.label}
className={
props.presentation.collapsed
? "ek-shell-nav__group"
: "ek-shell-nav__group ek-shell-nav__nested"
}
data-testid="discover-subnavigation"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{DISCOVER_VIEWS.map((view) => (
<NavigationItem
current={props.route.kind === "discover" && props.route.view === view}
destination={{ kind: "discover", view }}
history={props.history}
icon={DISCOVER_VIEW_COPY[view].icon}
key={view}
label={DISCOVER_VIEW_COPY[view].label}
{...onNavigate}
presentation={props.presentation}
testId={`select-discover-${view}`}
/>
))}
</ul>
</li>
) : 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 (
<section data-testid="settings-navigation">
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<ul className="ek-shell-nav__group" style={listStyle}>
<NavigationItem
current={route.kind === "settings"}
destination={{ kind: "settings" }}
history={props.history}
icon="settings"
label="Settings"
{...onNavigate}
presentation={props.presentation}
testId="select-settings"
/>
</ul>
{open ? (
<div
className={collapsed ? undefined : "ek-shell-nav__nested"}
data-testid="settings-subnavigation"
>
{settings.sections.length > 0 ? (
<>
<h3 className={labelClass} id={`${props.id}-settings-platform`}>
Platform
</h3>
<ul
aria-labelledby={`${props.id}-settings-platform`}
className="ek-shell-nav__group"
style={listStyle}
>
{settings.sections.map((section) =>
section === "discover" ? (
<DiscoverNavigation
history={props.history}
id={props.id}
key={section}
{...onNavigate}
presentation={props.presentation}
route={route}
/>
) : (
<NavigationItem
current={route.kind === "settings-section" && route.section === section}
destination={settingsSectionDestination(section)}
history={props.history}
icon={SETTINGS_SECTION_COPY[section].icon}
key={section}
label={SETTINGS_SECTION_COPY[section].label}
{...onNavigate}
presentation={props.presentation}
testId={`select-settings-${section}`}
/>
),
)}
</ul>
</>
) : undefined}
{modules.length > 0 ? (
<>
<h3 className={labelClass} id={`${props.id}-settings-modules`}>
Modules
</h3>
<ul
aria-labelledby={`${props.id}-settings-modules`}
className="ek-shell-nav__group"
style={listStyle}
>
{modules.map((module) => (
<NavigationItem
current={route.kind === "module-settings" && route.moduleId === module.id}
destination={{ kind: "module-settings", moduleId: module.id }}
history={props.history}
icon="module"
key={module.id}
label={module.name}
{...onNavigate}
presentation={props.presentation}
testId={`select-module-settings-${module.id}`}
/>
))}
</ul>
</>
) : undefined}
</div>
) : undefined}
</section>
);
}
/**
* 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 {
/>
</section>
) : undefined}
<SettingsNavigation
history={props.history}
id={props.id}
{...onNavigate}
presentation={props.presentation}
route={props.route}
settings={props.settings}
/>
</nav>
);
}
@@ -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<string>,
): 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";
}
}
@@ -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<string, LucideIcon>);
@@ -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")};
+95 -3
View File
@@ -9,7 +9,10 @@
*
* ```text
* / Overview
* /settings[/…] Settings
* /settings Settings
* /settings/<section> a platform Settings section: general, branding, displays, system
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
@@ -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-id>`. Module lifecycle — finding,
* installing, turning on and off, updating — is Discover, at `/settings/discover/…`; configuring a
* module is its own `/settings/<module-id>`. 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<SettingsSection, "discover">;
/**
* 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<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
const discoverViews: ReadonlySet<string> = 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") {
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)}`;
}
}
+97 -2
View File
@@ -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<string> = 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<DashboardSettings> {
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<HostBootstrap> {
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 };
}
+4
View File
@@ -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,
+79 -1
View File
@@ -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("/");
+2
View File
@@ -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",
]);
+56 -1
View File
@@ -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 () => {
+5 -2
View File
@@ -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/");
+7 -1
View File
@@ -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();
@@ -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<typeof vi.spyOn>;
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<Rendered> {
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(
<HostApp
config={{ apiBaseUrl, fetch: fetchImplementation }}
history={history}
importModule={async () => ({
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<HTMLElement>(`[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<void> {
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/<module-id>, 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();
});
});