Add the account area and account-synced Appearance preferences
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8571e6854c
commit
cd0287537e
@@ -0,0 +1,359 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user