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
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:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")};
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user