CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { designToken } from "@ekklesia/design-tokens";
|
|
import { Button, ErrorState, InlineNotice, PageHeader, Panel } from "@ekklesia/ui";
|
|
import {
|
|
type ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from "react";
|
|
import type { DashboardDestination } from "../dashboard-routes.js";
|
|
import {
|
|
type EkklesiaHostConfig,
|
|
type HostAccount,
|
|
type HostAccountPreferences,
|
|
HostProtocolError,
|
|
saveHostAccountPreferences,
|
|
} from "../host-protocol.js";
|
|
import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js";
|
|
import type { AppearancePreference } from "./appearance-preference.js";
|
|
import { dashboardBack } from "./dashboard-back.js";
|
|
import { DashboardLink } from "./DashboardLink.js";
|
|
import type { HostHistory } from "./host-history.js";
|
|
import { ShellIcon } from "./shell-icons.js";
|
|
|
|
/**
|
|
* The account area (DESIGN.md §9.2, ADR 0062): who you are signed in as, and your own settings.
|
|
*
|
|
* Personal, never church administration — that is Settings (ADR 0061). Nothing here needs a
|
|
* management permission, and nothing here can affect anyone else.
|
|
*/
|
|
|
|
const ACCOUNT: DashboardDestination = { kind: "account" };
|
|
const APPEARANCE: DashboardDestination = { kind: "account-section", section: "appearance" };
|
|
|
|
/**
|
|
* `/account`: the person's own account. Their name and email as they signed up with them, and the
|
|
* personal settings they can change. There is no profile editing yet, so nothing here pretends to
|
|
* be editable.
|
|
*/
|
|
export function AccountHomePage(props: {
|
|
readonly account: HostAccount;
|
|
readonly history: HostHistory;
|
|
readonly onSignOut: () => void;
|
|
}): ReactElement {
|
|
const { account } = props;
|
|
const hasName = account.name.trim().length > 0;
|
|
return (
|
|
<div data-testid="account-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
|
<PageHeader description="Who you're signed in as, and your own settings." title="Account" />
|
|
<Panel title="Signed in as">
|
|
<div
|
|
data-testid="account-identity"
|
|
style={{
|
|
alignItems: "center",
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: designToken("spacing.md"),
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<AccountAvatar account={account} size="lg" />
|
|
<dl
|
|
style={{
|
|
display: "grid",
|
|
gap: designToken("spacing.2xs"),
|
|
margin: 0,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
{hasName ? (
|
|
<div>
|
|
<dt className="ek-shell-visually-hidden">Name</dt>
|
|
<dd
|
|
data-testid="account-name"
|
|
style={{
|
|
fontWeight: designToken("typography.weight-medium"),
|
|
margin: 0,
|
|
overflowWrap: "anywhere",
|
|
}}
|
|
>
|
|
{accountDisplayName(account)}
|
|
</dd>
|
|
</div>
|
|
) : undefined}
|
|
<div>
|
|
<dt className="ek-shell-visually-hidden">Email</dt>
|
|
<dd
|
|
data-testid="account-email"
|
|
style={{
|
|
color: hasName ? designToken("text.secondary") : undefined,
|
|
margin: 0,
|
|
overflowWrap: "anywhere",
|
|
}}
|
|
>
|
|
{account.email}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</Panel>
|
|
<Panel title="Your settings">
|
|
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
<li>
|
|
<DashboardLink
|
|
className="ek-shell-settings-link"
|
|
data-testid="account-home-appearance"
|
|
history={props.history}
|
|
style={{ minHeight: designToken("control.height-lg") }}
|
|
to={APPEARANCE}
|
|
>
|
|
<ShellIcon name="appearance" />
|
|
<span className="ek-shell-settings-link__copy">
|
|
<span className="ek-shell-settings-link__label">Appearance</span>
|
|
<span className="ek-shell-settings-link__description">
|
|
Light, dark, or matching your device, and how much the Dashboard moves.
|
|
</span>
|
|
</span>
|
|
</DashboardLink>
|
|
</li>
|
|
</ul>
|
|
</Panel>
|
|
<div>
|
|
<Button data-testid="account-sign-out" onClick={props.onSignOut} variant="secondary">
|
|
<ShellIcon name="signOut" />
|
|
Sign out
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ChoiceOption<Value extends string> {
|
|
readonly value: Value;
|
|
readonly label: string;
|
|
readonly description: string;
|
|
}
|
|
|
|
const APPEARANCE_OPTIONS: readonly ChoiceOption<HostAccountPreferences["appearance"]>[] = [
|
|
{ description: "Match your device's light or dark setting.", label: "System", value: "system" },
|
|
{ description: "Always use the light appearance.", label: "Light", value: "light" },
|
|
{ description: "Always use the dark appearance.", label: "Dark", value: "dark" },
|
|
];
|
|
|
|
const MOTION_OPTIONS: readonly ChoiceOption<HostAccountPreferences["motion"]>[] = [
|
|
{
|
|
description: "Reduce motion when your device asks for less.",
|
|
label: "System",
|
|
value: "system",
|
|
},
|
|
{ description: "Always keep animation to a minimum.", label: "Reduce", value: "reduce" },
|
|
{
|
|
description: "Keep the Dashboard's subtle animation, even if your device asks for less.",
|
|
label: "Allow",
|
|
value: "allow",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* One preference as a native radio group: a `<fieldset>` named by its `<legend>`, each option a real
|
|
* `<input type="radio">` with its label and description. Arrow keys move between options, as the
|
|
* browser already does for a radio group, and the chosen option is marked by the radio itself and by
|
|
* weight, never by colour alone. Each row is a full-width touch target.
|
|
*/
|
|
function ChoiceGroup<Value extends string>(props: {
|
|
readonly name: string;
|
|
readonly legend: string;
|
|
readonly description: string;
|
|
readonly options: readonly ChoiceOption<Value>[];
|
|
readonly value: Value;
|
|
readonly onChange: (value: Value) => void;
|
|
}): ReactElement {
|
|
const descriptionId = useId();
|
|
return (
|
|
<fieldset
|
|
aria-describedby={descriptionId}
|
|
className="ek-shell-choice-group"
|
|
data-testid={`appearance-${props.name}`}
|
|
>
|
|
<legend className="ek-shell-choice-group__legend">{props.legend}</legend>
|
|
<p className="ek-shell-choice-group__description" id={descriptionId}>
|
|
{props.description}
|
|
</p>
|
|
<div className="ek-shell-choice-group__options">
|
|
{props.options.map((option) => {
|
|
const checked = option.value === props.value;
|
|
return (
|
|
<label
|
|
className="ek-shell-choice"
|
|
data-checked={checked ? "true" : undefined}
|
|
data-testid={`appearance-${props.name}-${option.value}`}
|
|
key={option.value}
|
|
style={{ minHeight: designToken("control.height-lg") }}
|
|
>
|
|
<input
|
|
checked={checked}
|
|
className="ek-shell-choice__input ek-focusable"
|
|
name={props.name}
|
|
onChange={() => props.onChange(option.value)}
|
|
type="radio"
|
|
value={option.value}
|
|
/>
|
|
<span className="ek-shell-choice__copy">
|
|
<span className="ek-shell-choice__label">{option.label}</span>
|
|
<span className="ek-shell-choice__description">{option.description}</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
type SaveStatus =
|
|
| { readonly kind: "idle" }
|
|
| { readonly kind: "saving" }
|
|
| { readonly kind: "saved" }
|
|
| { readonly kind: "failed" };
|
|
|
|
/**
|
|
* `/account/appearance`: Appearance (System, Light, Dark) and Motion (System, Reduce, Allow).
|
|
*
|
|
* A choice takes effect **at once**, with no reload and no Save button, and is saved to the account
|
|
* so it follows the person to every device they sign in on. The account is the source of truth:
|
|
* what the server answers is what the shell applies, and if saving fails, the choice is put back to
|
|
* what the account still holds, with a message saying so. Other open tabs of this Dashboard follow
|
|
* along through this browser's cache.
|
|
*/
|
|
export function AppearancePage(props: {
|
|
readonly account: HostAccount;
|
|
readonly appearance: AppearancePreference;
|
|
readonly config: EkklesiaHostConfig;
|
|
readonly history: HostHistory;
|
|
readonly onSaved: (preferences: HostAccountPreferences) => void;
|
|
readonly onSessionCheck: () => void;
|
|
readonly returnTarget?: unknown;
|
|
}): ReactElement {
|
|
const { appearance, config, onSaved, onSessionCheck } = props;
|
|
const subscribe = useCallback(
|
|
(listener: () => void) => appearance.subscribe(listener),
|
|
[appearance],
|
|
);
|
|
const read = useCallback(() => appearance.current(), [appearance]);
|
|
const current = useSyncExternalStore(subscribe, read, read);
|
|
const [status, setStatus] = useState<SaveStatus>({ kind: "idle" });
|
|
// What the account last confirmed: the value a failed save is put back to.
|
|
const confirmed = useRef<HostAccountPreferences>(props.account.preferences);
|
|
const sequence = useRef(0);
|
|
const inFlight = useRef(new Set<AbortController>());
|
|
|
|
useEffect(() => {
|
|
confirmed.current = props.account.preferences;
|
|
}, [props.account.preferences]);
|
|
|
|
useEffect(() => {
|
|
const controllers = inFlight.current;
|
|
return () => {
|
|
for (const controller of controllers) {
|
|
controller.abort();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const choose = (update: Partial<HostAccountPreferences>): void => {
|
|
appearance.apply({ ...appearance.current(), ...update });
|
|
sequence.current += 1;
|
|
const mine = sequence.current;
|
|
const controller = new AbortController();
|
|
inFlight.current.add(controller);
|
|
setStatus({ kind: "saving" });
|
|
void saveHostAccountPreferences(config, update, controller.signal)
|
|
.then((saved) => {
|
|
confirmed.current = saved;
|
|
onSaved(saved);
|
|
if (mine === sequence.current) {
|
|
appearance.apply(saved);
|
|
setStatus({ kind: "saved" });
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
if (mine === sequence.current) {
|
|
appearance.apply(confirmed.current);
|
|
setStatus({ kind: "failed" });
|
|
}
|
|
if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") {
|
|
// The session may have ended: the shell checks once, and signs the page out if so.
|
|
onSessionCheck();
|
|
}
|
|
})
|
|
.finally(() => {
|
|
inFlight.current.delete(controller);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
data-testid="account-appearance"
|
|
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
|
>
|
|
<PageHeader
|
|
back={dashboardBack(props.history, {
|
|
fallback: ACCOUNT,
|
|
label: (destination) => (destination.kind === "account" ? "Back to Account" : "Back"),
|
|
returnTarget: props.returnTarget,
|
|
})}
|
|
description="How the Dashboard looks for you, on every device you sign in on."
|
|
title="Appearance"
|
|
/>
|
|
<Panel>
|
|
<div style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
|
<ChoiceGroup
|
|
description="Light or dark, or the same as your device."
|
|
legend="Appearance"
|
|
name="appearance"
|
|
onChange={(value) => choose({ appearance: value })}
|
|
options={APPEARANCE_OPTIONS}
|
|
value={current.appearance}
|
|
/>
|
|
<ChoiceGroup
|
|
description="Animation and transitions in the Dashboard."
|
|
legend="Reduced motion"
|
|
name="motion"
|
|
onChange={(value) => choose({ motion: value })}
|
|
options={MOTION_OPTIONS}
|
|
value={current.motion}
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
<div aria-live="polite" data-testid="appearance-status">
|
|
{status.kind === "saved" ? (
|
|
<span className="ek-shell-visually-hidden">Saved to your account.</span>
|
|
) : undefined}
|
|
{status.kind === "failed" ? (
|
|
<InlineNotice title="Your choice wasn't saved" tone="danger">
|
|
It's back to what your account had. Check your connection and try again.
|
|
</InlineNotice>
|
|
) : undefined}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The account could not be loaded: the rest of the Dashboard still works, and Retry asks again. */
|
|
export function AccountLoadFailedPage(props: { readonly onRetry: () => void }): ReactElement {
|
|
return (
|
|
<div data-testid="account-load-failed">
|
|
<ErrorState
|
|
actions={<Button onClick={props.onRetry}>Retry</Button>}
|
|
description="The rest of the Dashboard still works. Try again in a moment."
|
|
title="Your account could not be loaded"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|