Merge pull request 'Release: Admin PWA follow-ups and hosting (0.7.1) (2026-09-24)' (#227) from release/2026-09-24-pwa-followups into main

Reviewed-on: #227
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
This commit was merged in pull request #227.
This commit is contained in:
2026-09-24 17:07:33 -04:00
18 changed files with 2165 additions and 21 deletions
+68
View File
@@ -1,5 +1,73 @@
# NestQuest Release Notes # NestQuest Release Notes
## Version 0.7.1 — 2026-09-24
### Scope
Patch release bringing the admin PWA follow-ups and its hosting deployment from
`dev` onto 0.7.0.
- **Feature 24 — Admin PWA Hosting:** the admin PWA's static hosting is now
reproducible from the repository — `deploy/pwa/Dockerfile` (multi-stage, pinned
`node`/`nginx` base images by digest, `VITE_AUTHENTIK_CLIENT_ID` /
`VITE_AUTHENTIK_ISSUER` / `VITE_API_BASE_URL` build args), `deploy/pwa/nginx.conf`
(SPA fallback plus a source allow-list for the reverse proxy and the host), a
`pwa` service in `deploy/compose.yaml` published on `:8081`, and a
`deploy/RUNBOOK.md` section describing the build/serve and the Traefik
`nestquest-app` router. (PRs #220, #221.)
- **Feature 19 follow-up — Manage children:** the Today header's Settings button
now opens a Settings screen. Its **Children** section lists the household's
children and lets a parent add (POST `/api/v1/admin/children`), edit
(PATCH `.../{id}`), activate/deactivate (PATCH `.../{id}/active`) and reorder
them (POST `.../reorder`). (PRs #222, #223.)
- **Feature 19 follow-up — Settings preferences:** the same screen has a
**Preferences** section for the planning horizon, day rollover time, notify
target, the morning/afternoon/end-of-day notification times and their toggles,
and celebration, read from `GET /api/v1/admin/settings` and saved with a partial
`PATCH` of only the changed fields. (PRs #224, #225, #226.)
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
- A running NestQuest API service as in 0.7.0 (the Feature 17 deployment); the
`admin/` PWA is served from the owner's deployment at
`https://nestquest.cubecraftlabs.com`, not by HACS.
### Breaking changes
- **None.** The schema is unchanged at version 8 and no migration runs.
`custom_components/` is unchanged from 0.7.0 apart from the manifest
release-version metadata — no Home Assistant entity, service, event or
automation surface changes. This release adds admin PWA screens and deployment
artifacts only.
### Known Limitations
- **HACS does not ship the admin PWA or the deployment artifacts.** HACS copies
only `custom_components/nestquest`, so upgrading via HACS changes nothing
functional over 0.7.0; the PWA screens and hosting are deployed from `admin/`
and `deploy/` per the runbook.
- Serving the PWA and API still requires the Feature 17 deployment (host, reverse
proxy, Authentik, backup target) as documented in `deploy/RUNBOOK.md`.
- **Feature 12's TouchHub/DAKOS runbook task remains physically unverified**
(needs the wall panel).
- The Settings UI does not expose resetting a field to its default with `null`.
### Rollback
Schema is unchanged (8), so no migration is involved in either direction.
`custom_components/` is unchanged from 0.7.0 apart from the release-version
metadata; rolling back the integration is a plain swap, and the PWA/hosting can be
redeployed from the 0.7.0 tree.
**HACS installs:** HACS > Integrations > NestQuest > Redownload > 0.7.0, then
restart Home Assistant.
**Manual installs:** replace `custom_components/nestquest/` with the 0.7.0 tree,
then restart Home Assistant.
**Repository operators:** git-revert the 0.7.1 release merge on `main`.
## Version 0.7.0 — 2026-09-24 ## Version 0.7.0 — 2026-09-24
### Scope ### Scope
+50
View File
@@ -0,0 +1,50 @@
/**
* Typed client for the admin child-profile write routes
* (/api/v1/admin/children). The list read is `fetchChildren` in ./definitions.
*/
import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson, type AdminChild } from "./definitions";
export interface ChildCreateBody {
display_name: string;
colour?: string;
avatar_ref?: string;
sort_order?: number;
}
/**
* Omitted fields stay unchanged. The API rejects an explicit null, so a set
* colour or avatar cannot be cleared through this route.
*/
export type ChildEditBody = Partial<ChildCreateBody>;
export const CHILDREN_REORDER_PATH = `${CHILDREN_PATH}/reorder`;
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
return apiFetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
export async function createChild(body: ChildCreateBody): Promise<AdminChild> {
return readJson<AdminChild>(await sendJson(CHILDREN_PATH, "POST", body));
}
export async function updateChild(id: number, body: ChildEditBody): Promise<AdminChild> {
return readJson<AdminChild>(await sendJson(`${CHILDREN_PATH}/${id}`, "PATCH", body));
}
export async function setChildActive(id: number, isActive: boolean): Promise<AdminChild> {
return readJson<AdminChild>(
await sendJson(`${CHILDREN_PATH}/${id}/active`, "PATCH", { is_active: isActive }),
);
}
/** `orderedIds` must be every child id in the household, in the new order. */
export async function reorderChildren(orderedIds: number[]): Promise<void> {
await readJson<{ status: string }>(
await sendJson(CHILDREN_REORDER_PATH, "POST", { ordered_ids: orderedIds }),
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Typed client for the admin household-settings routes
* (/api/v1/admin/settings).
*/
import { apiFetch } from "./client";
import { readJson } from "./definitions";
export interface HouseholdSettings {
horizon_days: number;
/** Local wall-clock "HH:MM", as are the other *_time fields. */
day_rollover_time: string;
notify_target: string;
morning_summary_time: string;
afternoon_reminder_time: string;
end_of_day_report_time: string;
morning_summary_enabled: boolean;
afternoon_reminder_enabled: boolean;
end_of_day_report_enabled: boolean;
celebration_enabled: boolean;
}
/** Only the supplied fields are updated; the API answers with all of them. */
export type SettingsChanges = Partial<HouseholdSettings>;
export const SETTINGS_PATH = "/api/v1/admin/settings";
export async function fetchSettings(): Promise<HouseholdSettings> {
return readJson<HouseholdSettings>(await apiFetch(SETTINGS_PATH));
}
export async function updateSettings(changes: SettingsChanges): Promise<HouseholdSettings> {
return readJson<HouseholdSettings>(
await apiFetch(SETTINGS_PATH, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
}),
);
}
+2 -2
View File
@@ -45,7 +45,7 @@ function errorMessage(error: unknown, fallback: string): string {
return fallback; return fallback;
} }
const FOCUSABLE = export const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
/** /**
@@ -53,7 +53,7 @@ const FOCUSABLE =
* <body>), so the tab bar behind the pushed screen cannot take focus. * <body>), so the tab bar behind the pushed screen cannot take focus.
* Returns the undo. * Returns the undo.
*/ */
function inertOutside(root: HTMLElement): () => void { export function inertOutside(root: HTMLElement): () => void {
const made: HTMLElement[] = []; const made: HTMLElement[] = [];
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) { for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
for (const sibling of Array.from(node.parentElement.children)) { for (const sibling of Array.from(node.parentElement.children)) {
+367
View File
@@ -0,0 +1,367 @@
/* Settings — pushed screen over the Today tab (and the tab bar), like the
presence override editor. Compact density list, roomy form card. */
.settings-screen {
position: fixed;
inset: 0;
z-index: 30;
overflow-y: auto;
background: var(--nq-a-page);
}
.settings-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
}
.settings-back {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -6px 0 -6px -12px;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink);
cursor: pointer;
}
.settings-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.settings-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.settings-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 14px 16px;
}
.settings-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-section-label {
margin: 0;
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.settings-add {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.settings-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.settings-list {
margin: 0;
padding: 0;
list-style: none;
}
.settings-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.settings-child {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: var(--nq-a-density-row-pad);
}
.settings-child--inactive .settings-child-name {
color: var(--nq-a-ink-tertiary);
}
.settings-swatch {
flex: none;
width: 22px;
height: 22px;
border-radius: var(--nq-a-radius-pill);
border: 1px solid rgba(9, 9, 11, 0.12);
}
.settings-child-text {
flex: 1;
min-width: 0;
}
.settings-child-name {
font-size: var(--nq-a-size-row);
font-weight: 700;
}
.settings-child-meta {
margin-top: 1px;
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-tertiary);
}
.settings-pill {
flex: none;
padding: 2px 8px;
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-divider);
color: var(--nq-a-ink-secondary);
font-size: var(--nq-a-size-meta);
font-weight: 700;
}
.settings-pill--active {
background: #dcfce7;
color: var(--nq-a-success);
}
.settings-child-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
width: 100%;
}
.settings-icon-button,
.settings-action {
min-height: 36px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.settings-icon-button {
display: grid;
place-items: center;
width: 36px;
padding: 0;
}
.settings-action {
padding: 0 12px;
}
.settings-icon-button:disabled,
.settings-action:disabled,
.settings-add:disabled,
.settings-back:disabled,
.settings-cancel:disabled,
.settings-save:disabled {
opacity: 0.45;
cursor: default;
}
.settings-empty,
.settings-status {
padding: 16px;
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
text-align: center;
}
.settings-status--error {
color: var(--nq-a-danger-strong);
}
.settings-status p {
margin: 0 0 10px;
}
.settings-action-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
/* Add / edit form: roomy density, 18px padding, 16px gaps. */
.settings-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
}
.settings-form-title {
margin: 0;
font-size: 17px;
font-weight: 800;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.settings-label {
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.settings-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 13px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.settings-swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-swatch-option {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid transparent;
border-radius: var(--nq-a-radius-pill);
cursor: pointer;
}
.settings-swatch-option[aria-pressed="true"] {
border-color: var(--nq-a-ink);
box-shadow: inset 0 0 0 2px var(--nq-a-surface);
}
.settings-error {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
.settings-form-footer {
display: flex;
gap: 10px;
}
.settings-cancel,
.settings-save {
padding: 15px 12px;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.settings-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.settings-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
/* Preferences: one box per notification — its toggle, then its time. */
.settings-notification {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--nq-a-divider);
border-radius: var(--nq-a-density-radius);
}
.settings-toggle {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.settings-toggle > input {
width: 20px;
height: 20px;
margin: 0;
}
.settings-notice {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-divider);
color: var(--nq-a-ink-secondary);
font-size: 12.5px;
font-weight: 700;
}
+514
View File
@@ -0,0 +1,514 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
import type { AdminChild } from "../api/definitions";
import type { HouseholdSettings } from "../api/settings";
import { storeTokens } from "../auth/oidc";
const CHILDREN_PATH = "/api/v1/admin/children";
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
const SETTINGS_PATH = "/api/v1/admin/settings";
const DEFAULT_SETTINGS: HouseholdSettings = {
horizon_days: 14,
day_rollover_time: "03:00",
notify_target: "notify.family",
morning_summary_time: "07:00",
afternoon_reminder_time: "15:30",
end_of_day_report_time: "20:00",
morning_summary_enabled: true,
afternoon_reminder_enabled: false,
end_of_day_report_enabled: true,
celebration_enabled: false,
};
function child(
id: number,
display_name: string,
sort_order: number,
extra: Partial<AdminChild> = {},
): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true, ...extra };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
let children: AdminChild[];
let settings: HouseholdSettings;
/** Overrides the next write's response (the store is left untouched). */
let writeResponse: (() => Response) | null;
let fetchMock: ReturnType<typeof vi.fn>;
function calls(): Call[] {
return fetchMock.mock.calls.map((args: unknown[]) => {
const [url, init] = args as [string, RequestInit | undefined];
return {
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
};
});
}
function writes(): Call[] {
return calls().filter((c) => c.method !== "GET");
}
function childGets(): number {
return calls().filter((c) => c.method === "GET" && c.path === CHILDREN_PATH).length;
}
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const path = new URL(url, "http://x").pathname;
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (method !== "GET" && writeResponse) {
const respond = writeResponse;
writeResponse = null;
return respond();
}
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body };
return jsonResponse(settings);
}
if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, {
colour: body.colour ?? null,
avatar_ref: body.avatar_ref ?? null,
});
children = [...children, created];
return jsonResponse(created, 201);
}
if (path === REORDER_PATH && method === "POST") {
const ids = body.ordered_ids as number[];
children = children.map((c) => ({ ...c, sort_order: ids.indexOf(c.id) + 1 }));
return jsonResponse({ status: "ok" });
}
const activeMatch = /\/children\/(\d+)\/active$/.exec(path);
if (activeMatch && method === "PATCH") {
const id = Number(activeMatch[1]);
children = children.map((c) => (c.id === id ? { ...c, is_active: body.is_active } : c));
return jsonResponse(children.find((c) => c.id === id));
}
const editMatch = /\/children\/(\d+)$/.exec(path);
if (editMatch && method === "PATCH") {
const id = Number(editMatch[1]);
children = children.map((c) => (c.id === id ? { ...c, ...body } : c));
return jsonResponse(children.find((c) => c.id === id));
}
if (path.endsWith("/admin/snapshot")) {
return jsonResponse({ today_iso: "2026-09-23", cycle_day: 5, children: [] });
}
return jsonResponse({ detail: "unexpected" }, 500);
}
function row(id: number) {
return within(screen.getByTestId(`settings-child-${id}`));
}
function listNames(): string[] {
return within(screen.getByRole("list", { name: "Children" }))
.getAllByRole("listitem")
.map((li) => li.querySelector(".settings-child-name")?.textContent ?? "");
}
function field(form: HTMLElement, label: string): HTMLInputElement {
return within(form).getByLabelText(label) as HTMLInputElement;
}
async function renderScreen(onClose = vi.fn()) {
render(<SettingsScreen onClose={onClose} />);
await screen.findByTestId("settings-child-1");
return onClose;
}
describe("SettingsScreen — children", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [
child(2, "Maeve", 2, { colour: "#16a34a" }),
child(1, "Declan", 1, { colour: "#0ea5e9", avatar_ref: "fox" }),
child(3, "Rory", 3, { is_active: false }),
];
settings = { ...DEFAULT_SETTINGS };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("lists each child with name, colour swatch, active state and order", async () => {
await renderScreen();
expect(listNames()).toEqual(["Declan", "Maeve", "Rory"]);
expect(row(1).getByText("Order 1 · #0ea5e9")).toBeTruthy();
expect(screen.getByTestId("settings-swatch-1").style.background).toBe("rgb(14, 165, 233)");
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
expect(row(3).getByText("Order 3 · no colour")).toBeTruthy();
expect(screen.getByTestId("settings-swatch-3").style.background).toBe("var(--nq-a-border)");
expect(screen.getByTestId("settings-active-3").textContent).toBe("Inactive");
expect(screen.getByTestId("settings-scroll").style.paddingBottom).toBe(SCROLL_BOTTOM_PADDING);
});
it("Add posts exactly the entered values, then shows the new child", async () => {
await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
expect(field(form, "Order").value).toBe("4");
fireEvent.change(field(form, "Display name"), { target: { value: " Nora " } });
fireEvent.click(within(form).getByRole("button", { name: "Colour #7c3aed" }));
fireEvent.change(field(form, "Avatar ref (optional)"), { target: { value: "owl" } });
fireEvent.change(field(form, "Order"), { target: { value: "5" } });
const gets = childGets();
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
await screen.findByTestId("settings-child-9");
expect(writes()).toEqual([
{
method: "POST",
path: CHILDREN_PATH,
body: { display_name: "Nora", colour: "#7c3aed", avatar_ref: "owl", sort_order: 5 },
},
]);
expect(childGets()).toBe(gets + 1);
expect(listNames()).toEqual(["Declan", "Maeve", "Rory", "Nora"]);
expect(screen.queryByRole("form", { name: "Add child" })).toBeNull();
});
it("Add omits optional fields left blank and rejects a missing name", async () => {
await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
fireEvent.change(field(form, "Order"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
expect(screen.getByTestId("child-form-error").textContent).toBe("Enter a display name.");
expect(writes()).toEqual([]);
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
await screen.findByTestId("settings-child-9");
expect(writes()[0].body).toEqual({ display_name: "Nora" });
});
it("Edit patches only the changed fields", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
const form = screen.getByRole("form", { name: "Edit Declan" });
expect(field(form, "Display name").value).toBe("Declan");
expect(field(form, "Colour").value).toBe("#0ea5e9");
fireEvent.change(field(form, "Display name"), { target: { value: "Dec" } });
fireEvent.change(field(form, "Colour"), { target: { value: "#FF8800" } });
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
await waitFor(() => expect(row(1).getByText("Dec")).toBeTruthy());
expect(writes()).toEqual([
{ method: "PATCH", path: `${CHILDREN_PATH}/1`, body: { display_name: "Dec", colour: "#FF8800" } },
]);
expect(row(1).getByText("Order 1 · #FF8800")).toBeTruthy();
});
it("Edit refuses to clear a set colour instead of sending null", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Edit Declan" }));
const form = screen.getByRole("form", { name: "Edit Declan" });
fireEvent.change(field(form, "Colour"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save child" }));
expect(screen.getByTestId("child-form-error").textContent).toMatch(/cannot be removed/);
expect(writes()).toEqual([]);
});
it("the active toggle patches /children/{id}/active with the new value", async () => {
await renderScreen();
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
await waitFor(() => expect(screen.getByTestId("settings-active-1").textContent).toBe("Inactive"));
fireEvent.click(row(3).getByRole("button", { name: "Reactivate Rory" }));
await waitFor(() => expect(screen.getByTestId("settings-active-3").textContent).toBe("Active"));
expect(writes()).toEqual([
{ method: "PATCH", path: `${CHILDREN_PATH}/1/active`, body: { is_active: false } },
{ method: "PATCH", path: `${CHILDREN_PATH}/3/active`, body: { is_active: true } },
]);
});
it("reorder posts the complete ordered id list, then shows the new order", async () => {
await renderScreen();
expect((row(1).getByRole("button", { name: "Move Declan up" }) as HTMLButtonElement).disabled).toBe(true);
expect((row(3).getByRole("button", { name: "Move Rory down" }) as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(row(3).getByRole("button", { name: "Move Rory up" }));
await waitFor(() => expect(listNames()).toEqual(["Declan", "Rory", "Maeve"]));
expect(writes()).toEqual([{ method: "POST", path: REORDER_PATH, body: { ordered_ids: [1, 3, 2] } }]);
});
it("disables the write controls while a request is in flight (no double submit)", async () => {
await renderScreen();
let release!: () => void;
writeResponse = () => jsonResponse({ status: "ok" });
const gate = new Promise<void>((r) => {
release = r;
});
fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => {
await gate;
return routeFetch(url, init);
});
const up = row(2).getByRole("button", { name: "Move Maeve up" }) as HTMLButtonElement;
fireEvent.click(up);
fireEvent.click(up);
await waitFor(() => expect(up.disabled).toBe(true));
expect((row(1).getByRole("button", { name: "Deactivate Declan" }) as HTMLButtonElement).disabled).toBe(true);
expect(writes()).toHaveLength(1);
release();
await waitFor(() => expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(false));
expect(writes()).toHaveLength(1);
});
it("a 422 shows the API detail and keeps the form and the screen open", async () => {
const onClose = await renderScreen();
fireEvent.click(screen.getByRole("button", { name: "Add child" }));
const form = screen.getByRole("form", { name: "Add child" });
fireEvent.change(field(form, "Display name"), { target: { value: "Nora" } });
writeResponse = () => jsonResponse({ detail: "display_name is required" }, 422);
fireEvent.click(within(form).getByRole("button", { name: "Add child" }));
const error = await screen.findByTestId("child-form-error");
expect(error.textContent).toBe("The child could not be added: display_name is required");
expect(screen.getByRole("form", { name: "Add child" })).toBeTruthy();
expect(field(form, "Display name").value).toBe("Nora");
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy();
});
it("a 403 on a row action shows the refusal message and keeps the screen open", async () => {
const onClose = await renderScreen();
writeResponse = () => jsonResponse({ detail: "forbidden" }, 403);
fireEvent.click(row(1).getByRole("button", { name: "Deactivate Declan" }));
const error = await screen.findByTestId("settings-action-error");
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
expect(screen.getByTestId("settings-active-1").textContent).toBe("Active");
expect(onClose).not.toHaveBeenCalled();
});
it("a 403 on load shows the refusal message", async () => {
fetchMock.mockImplementation(async () => jsonResponse({ detail: "forbidden" }, 403));
render(<SettingsScreen onClose={vi.fn()} />);
const error = await screen.findByTestId("settings-error");
expect(error.textContent).toContain("Your account is not in the nestquest-admins group.");
});
it("Escape and Back close the screen", async () => {
const onClose = await renderScreen();
fireEvent.keyDown(screen.getByRole("dialog", { name: "Settings" }), { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("the Today Settings button opens the screen; closing restores focus and the background", async () => {
render(<App />);
const settings = await screen.findByRole("button", { name: "Settings" });
settings.focus();
fireEvent.click(settings);
const dialog = await screen.findByRole("dialog", { name: "Settings" });
await screen.findByTestId("settings-child-1");
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Settings" }));
const tabbar = document.querySelector("nav.admin-tabbar") as HTMLElement;
expect(tabbar.hasAttribute("inert")).toBe(true);
expect(dialog.closest("[inert]")).toBeNull();
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull());
expect(tabbar.hasAttribute("inert")).toBe(false);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Settings" }));
});
});
describe("SettingsScreen — preferences", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderPreferences(onClose = vi.fn()) {
render(<SettingsScreen onClose={onClose} />);
await screen.findByTestId("settings-child-1");
const form = await screen.findByRole("form", { name: "Preferences" });
return { form, onClose };
}
function checkbox(form: HTMLElement, label: string): HTMLInputElement {
return within(form).getByRole("checkbox", { name: label }) as HTMLInputElement;
}
function save(form: HTMLElement) {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
it("renders the current value of all ten fields", async () => {
const { form } = await renderPreferences();
expect(field(form, "Planning horizon (days)").value).toBe("14");
expect(field(form, "Day rollover").value).toBe("03:00");
expect(field(form, "Notify target").value).toBe("notify.family");
expect(field(form, "Morning summary time").value).toBe("07:00");
expect(field(form, "Afternoon reminder time").value).toBe("15:30");
expect(field(form, "End-of-day report time").value).toBe("20:00");
expect(checkbox(form, "Morning summary").checked).toBe(true);
expect(checkbox(form, "Afternoon reminder").checked).toBe(false);
expect(checkbox(form, "End-of-day report").checked).toBe(true);
expect(checkbox(form, "Celebration").checked).toBe(false);
});
it("saving one changed field patches exactly that field", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { horizon_days: 21 } }]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Planning horizon (days)").value).toBe("21");
});
it("saving several changed fields sends exactly those", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Day rollover"), { target: { value: "04:15" } });
fireEvent.change(field(form, "Notify target"), { target: { value: " notify.parents " } });
fireEvent.change(field(form, "End-of-day report time"), { target: { value: "21:45" } });
fireEvent.click(checkbox(form, "Afternoon reminder"));
fireEvent.click(checkbox(form, "Celebration"));
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{
method: "PATCH",
path: SETTINGS_PATH,
body: {
day_rollover_time: "04:15",
notify_target: "notify.parents",
end_of_day_report_time: "21:45",
afternoon_reminder_enabled: true,
celebration_enabled: true,
},
},
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(checkbox(reloaded, "Celebration").checked).toBe(true);
expect(field(reloaded, "Notify target").value).toBe("notify.parents");
});
it("with nothing changed it says so and sends no request", async () => {
const { form } = await renderPreferences();
fireEvent.click(checkbox(form, "Celebration"));
fireEvent.click(checkbox(form, "Celebration"));
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
expect(writes()).toEqual([]);
});
it("a 422 shows the API detail naming the field and keeps the edit", async () => {
const { form, onClose } = await renderPreferences();
fireEvent.change(field(form, "Notify target"), { target: { value: "notify.nobody" } });
writeResponse = () => jsonResponse({ detail: "notify_target is not a notify service" }, 422);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toBe(
"The preferences could not be saved: notify_target is not a notify service",
);
expect(message.getAttribute("role")).toBe("alert");
expect(field(form, "Notify target").value).toBe("notify.nobody");
expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { notify_target: "notify.nobody" } },
]);
expect(onClose).not.toHaveBeenCalled();
});
it("a 403 on save shows the refusal message", async () => {
const { form } = await renderPreferences();
fireEvent.click(checkbox(form, "Celebration"));
writeResponse = () => jsonResponse({ detail: "forbidden" }, 403);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toContain("Your account is not in the nestquest-admins group.");
expect(checkbox(form, "Celebration").checked).toBe(true);
});
it.each([
["empty", ""],
["zero", "0"],
["oversized (Infinity would serialize as null and reset the field)", "9".repeat(400)],
["past the safe-integer range", "123456789012345678901"],
])("a %s horizon is rejected before any request", async (_case, value) => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Planning horizon must be a whole number of days, at least 1.",
);
expect(writes()).toEqual([]);
});
it("disables every write control while a save is in flight (no double submit)", async () => {
const { form } = await renderPreferences();
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => {
await gate;
return routeFetch(url, init);
});
fireEvent.click(checkbox(form, "Celebration"));
const button = within(form).getByRole("button", { name: "Save preferences" }) as HTMLButtonElement;
fireEvent.click(button);
fireEvent.click(button);
await waitFor(() => expect(button.disabled).toBe(true));
expect(checkbox(form, "Celebration").disabled).toBe(true);
expect(field(form, "Planning horizon (days)").disabled).toBe(true);
expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(true);
expect(writes()).toHaveLength(1);
release();
await screen.findByText("Preferences saved.");
expect(writes()).toHaveLength(1);
});
it("a failed load shows the reason and Try again refetches", async () => {
let fail = true;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (fail && new URL(url, "http://x").pathname === SETTINGS_PATH) {
return jsonResponse({ detail: "boom" }, 500);
}
return routeFetch(url, init);
});
render(<SettingsScreen onClose={vi.fn()} />);
const error = await screen.findByTestId("preferences-error");
expect(error.textContent).toContain("Preferences could not be loaded: boom");
fail = false;
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
await screen.findByRole("form", { name: "Preferences" });
});
});
+811
View File
@@ -0,0 +1,811 @@
/**
* Settings — a pushed screen opened from the Today header's Settings button.
*
* Children: list, add, edit, deactivate/reactivate and reorder the
* household's child profiles. Every write refetches the list.
*
* Preferences: the household settings (planning horizon, day rollover,
* notifications). Save patches only the changed fields.
*
* One write at a time across both sections.
*/
import {
useEffect,
useRef,
useState,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { ApiForbiddenError } from "../api/client";
import {
createChild,
reorderChildren,
setChildActive,
updateChild,
type ChildCreateBody,
type ChildEditBody,
} from "../api/children";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import {
fetchSettings,
updateSettings,
type HouseholdSettings,
type SettingsChanges,
} from "../api/settings";
import { FOCUSABLE, inertOutside } from "../schedule/OverrideEditor";
import { ArrowDownGlyph, ArrowUpGlyph, ChevronLeftGlyph, PlusGlyph } from "./glyphs";
import "./SettingsScreen.css";
/** The swatch picker's presets; any other "#RRGGBB" can be typed. */
export const COLOUR_PRESETS = ["#e11d48", "#f97316", "#eab308", "#16a34a", "#0ea5e9", "#7c3aed"];
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/;
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; children: AdminChild[] };
type FormTarget = null | "new" | AdminChild;
interface Draft {
name: string;
colour: string;
avatar: string;
order: string;
}
/** Display order: sort_order, ties broken by id (the API's own order). */
function sortChildren(children: AdminChild[]): AdminChild[] {
return [...children].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id);
}
function errorMessage(error: unknown, action: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return `${action}: ${error.detail}`;
return `${action}. Check your connection and try again.`;
}
function draftFor(target: "new" | AdminChild, siblings: AdminChild[]): Draft {
if (target === "new") {
const next = siblings.reduce((max, c) => Math.max(max, c.sort_order), 0) + 1;
return { name: "", colour: "", avatar: "", order: String(next) };
}
return {
name: target.display_name,
colour: target.colour ?? "",
avatar: target.avatar_ref ?? "",
order: String(target.sort_order),
};
}
/** The request body for the draft, or a validation message. */
function buildBody(
draft: Draft,
target: "new" | AdminChild,
): ChildCreateBody | ChildEditBody | string {
const name = draft.name.trim();
const colour = draft.colour.trim();
const avatar = draft.avatar.trim();
const order = draft.order.trim();
if (!name) return "Enter a display name.";
if (colour && !HEX_COLOUR.test(colour)) return "Colour must be a hex value like #0ea5e9.";
if (order && !/^-?\d+$/.test(order)) return "Order must be a whole number.";
if (target === "new") {
const body: ChildCreateBody = { display_name: name };
if (colour) body.colour = colour;
if (avatar) body.avatar_ref = avatar;
if (order) body.sort_order = Number(order);
return body;
}
// The API rejects null, so a set colour, avatar or order cannot be cleared.
if (!colour && target.colour !== null) return "A colour, once set, cannot be removed. Pick another.";
if (!avatar && target.avatar_ref !== null) return "An avatar ref, once set, cannot be removed.";
if (!order) return "Enter an order.";
const body: ChildEditBody = {};
if (name !== target.display_name) body.display_name = name;
if (colour && colour !== target.colour) body.colour = colour;
if (avatar && avatar !== target.avatar_ref) body.avatar_ref = avatar;
if (Number(order) !== target.sort_order) body.sort_order = Number(order);
if (Object.keys(body).length === 0) return "No changes to save.";
return body;
}
function ChildForm({
target,
siblings,
saving,
error,
onCancel,
onSubmit,
}: {
target: "new" | AdminChild;
/** The current list; a new child's order defaults to after the last. */
siblings: AdminChild[];
saving: boolean;
error: string | null;
onCancel: () => void;
onSubmit: (body: ChildCreateBody | ChildEditBody | string) => void;
}) {
const [draft, setDraft] = useState<Draft>(() => draftFor(target, siblings));
const nameRef = useRef<HTMLInputElement>(null);
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
const isNew = target === "new";
const title = isNew ? "Add child" : `Edit ${target.display_name}`;
useEffect(() => {
nameRef.current?.focus();
}, []);
function submit(event: FormEvent) {
event.preventDefault();
onSubmit(buildBody(draft, target));
}
return (
<form className="settings-card settings-form" aria-label={title} onSubmit={submit} noValidate>
<h4 className="settings-form-title">{title}</h4>
<label className="settings-field">
<span className="settings-label">Display name</span>
<input
ref={nameRef}
className="settings-input"
type="text"
required
value={draft.name}
disabled={saving}
onChange={(event) => update({ name: event.target.value })}
/>
</label>
<div className="settings-field">
<label className="settings-field">
<span className="settings-label">Colour</span>
<input
className="settings-input"
type="text"
placeholder="#0ea5e9"
value={draft.colour}
disabled={saving}
onChange={(event) => update({ colour: event.target.value })}
/>
</label>
<div className="settings-swatches" role="group" aria-label="Colour presets">
{COLOUR_PRESETS.map((hex) => (
<button
key={hex}
type="button"
className="settings-swatch-option"
style={{ background: hex }}
aria-label={`Colour ${hex}`}
aria-pressed={draft.colour.toLowerCase() === hex}
disabled={saving}
onClick={() => update({ colour: hex })}
/>
))}
</div>
</div>
<label className="settings-field">
<span className="settings-label">Avatar ref (optional)</span>
<input
className="settings-input"
type="text"
value={draft.avatar}
disabled={saving}
onChange={(event) => update({ avatar: event.target.value })}
/>
</label>
<label className="settings-field">
<span className="settings-label">Order</span>
<input
className="settings-input"
type="text"
inputMode="numeric"
value={draft.order}
disabled={saving}
onChange={(event) => update({ order: event.target.value })}
/>
</label>
{error ? (
<p className="settings-error" role="alert" data-testid="child-form-error">
{error}
</p>
) : null}
<div className="settings-form-footer">
<button type="button" className="settings-cancel" disabled={saving} onClick={onCancel}>
Cancel
</button>
<button type="submit" className="settings-save" disabled={saving} aria-busy={saving}>
{saving ? "Saving…" : isNew ? "Add child" : "Save child"}
</button>
</div>
</form>
);
}
type PreferencesState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; settings: HouseholdSettings };
type TimeField =
| "day_rollover_time"
| "morning_summary_time"
| "afternoon_reminder_time"
| "end_of_day_report_time";
type ToggleField =
| "morning_summary_enabled"
| "afternoon_reminder_enabled"
| "end_of_day_report_enabled"
| "celebration_enabled";
const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] = [
{ label: "Morning summary", time: "morning_summary_time", toggle: "morning_summary_enabled" },
{ label: "Afternoon reminder", time: "afternoon_reminder_time", toggle: "afternoon_reminder_enabled" },
{ label: "End-of-day report", time: "end_of_day_report_time", toggle: "end_of_day_report_enabled" },
];
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
/** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
function preferencesDraft(settings: HouseholdSettings): PreferencesDraft {
return { ...settings, horizon_days: String(settings.horizon_days) };
}
/** Only the fields that differ from `loaded`, or a validation message. */
function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): SettingsChanges | string {
const horizon = draft.horizon_days.trim();
const horizonDays = Number(horizon);
// A digit string too long for a safe integer would become Infinity, which
// JSON serializes as null, and the API reads null as "reset to default".
if (!/^\d+$/.test(horizon) || !Number.isSafeInteger(horizonDays) || horizonDays < 1) {
return "Planning horizon must be a whole number of days, at least 1.";
}
const times: [TimeField, string][] = [
["day_rollover_time", "Day rollover"],
...NOTIFICATIONS.map((n): [TimeField, string] => [n.time, `${n.label} time`]),
];
for (const [key, label] of times) {
if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`;
}
const next: HouseholdSettings = {
...draft,
horizon_days: horizonDays,
notify_target: draft.notify_target.trim(),
};
const changes: SettingsChanges = {};
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
}
if (Object.keys(changes).length === 0) return "No changes to save.";
return changes;
}
function PreferencesForm({
settings,
saving,
locked,
message,
onSubmit,
}: {
settings: HouseholdSettings;
saving: boolean;
/** Another write is in flight. */
locked: boolean;
message: { kind: "error" | "info"; text: string } | null;
onSubmit: (changes: SettingsChanges | string) => void;
}) {
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch }));
const disabled = saving || locked;
function submit(event: FormEvent) {
event.preventDefault();
onSubmit(buildChanges(draft, settings));
}
function timeInput(key: TimeField, label: string) {
return (
<label className="settings-field">
<span className="settings-label">{label}</span>
<input
className="settings-input"
type="time"
value={draft[key]}
disabled={disabled}
onChange={(event) => update({ [key]: event.target.value })}
/>
</label>
);
}
function toggle(key: ToggleField, label: string) {
return (
<label className="settings-toggle">
<input
type="checkbox"
checked={draft[key]}
disabled={disabled}
onChange={(event) => update({ [key]: event.target.checked })}
/>
<span>{label}</span>
</label>
);
}
return (
<form className="settings-card settings-form" aria-label="Preferences" onSubmit={submit} noValidate>
<label className="settings-field">
<span className="settings-label">Planning horizon (days)</span>
<input
className="settings-input"
type="number"
min={1}
step={1}
inputMode="numeric"
value={draft.horizon_days}
disabled={disabled}
onChange={(event) => update({ horizon_days: event.target.value })}
/>
</label>
{timeInput("day_rollover_time", "Day rollover")}
<label className="settings-field">
<span className="settings-label">Notify target</span>
<input
className="settings-input"
type="text"
value={draft.notify_target}
disabled={disabled}
onChange={(event) => update({ notify_target: event.target.value })}
/>
</label>
{NOTIFICATIONS.map((n) => (
<div key={n.toggle} className="settings-notification">
{toggle(n.toggle, n.label)}
{timeInput(n.time, `${n.label} time`)}
</div>
))}
{toggle("celebration_enabled", "Celebration")}
{message ? (
<p
className={message.kind === "error" ? "settings-error" : "settings-notice"}
role={message.kind === "error" ? "alert" : "status"}
data-testid="preferences-message"
>
{message.text}
</p>
) : null}
<div className="settings-form-footer">
<button type="submit" className="settings-save" disabled={disabled} aria-busy={saving}>
{saving ? "Saving…" : "Save preferences"}
</button>
</div>
</form>
);
}
function ChildRow({
child,
index,
count,
busy,
onEdit,
onMove,
onToggleActive,
}: {
child: AdminChild;
index: number;
count: number;
/** The in-flight action key, or null. Every write control waits on it. */
busy: string | null;
onEdit: () => void;
onMove: (delta: -1 | 1) => void;
onToggleActive: () => void;
}) {
const locked = busy !== null;
const toggling = busy === `active-${child.id}`;
return (
<li
className={child.is_active ? "settings-child" : "settings-child settings-child--inactive"}
data-testid={`settings-child-${child.id}`}
>
<span
className="settings-swatch"
data-testid={`settings-swatch-${child.id}`}
style={{ background: child.colour ?? "var(--nq-a-border)" }}
aria-hidden="true"
/>
<div className="settings-child-text">
<div className="settings-child-name">{child.display_name}</div>
<div className="settings-child-meta">
Order {child.sort_order} · {child.colour ?? "no colour"}
</div>
</div>
<span
className={child.is_active ? "settings-pill settings-pill--active" : "settings-pill"}
data-testid={`settings-active-${child.id}`}
>
{child.is_active ? "Active" : "Inactive"}
</span>
<div className="settings-child-actions">
<button
type="button"
className="settings-icon-button"
aria-label={`Move ${child.display_name} up`}
aria-busy={busy === `move-${child.id}`}
disabled={locked || index === 0}
onClick={() => onMove(-1)}
>
<ArrowUpGlyph />
</button>
<button
type="button"
className="settings-icon-button"
aria-label={`Move ${child.display_name} down`}
aria-busy={busy === `move-${child.id}`}
disabled={locked || index === count - 1}
onClick={() => onMove(1)}
>
<ArrowDownGlyph />
</button>
<button
type="button"
className="settings-action"
aria-label={`Edit ${child.display_name}`}
disabled={locked}
onClick={onEdit}
>
Edit
</button>
<button
type="button"
className="settings-action"
aria-label={`${child.is_active ? "Deactivate" : "Reactivate"} ${child.display_name}`}
aria-busy={toggling}
disabled={locked}
onClick={onToggleActive}
>
{toggling ? "Saving…" : child.is_active ? "Deactivate" : "Reactivate"}
</button>
</div>
</li>
);
}
export default function SettingsScreen({ onClose }: { onClose: () => void }) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [form, setForm] = useState<FormTarget>(null);
const [formError, setFormError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [preferences, setPreferences] = useState<PreferencesState>({ kind: "loading" });
const [preferencesAttempt, setPreferencesAttempt] = useState(0);
const [preferencesMessage, setPreferencesMessage] = useState<{
kind: "error" | "info";
text: string;
} | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null);
const formWasOpen = useRef(false);
// Modal focus: move in on open, keep the background out of reach, and hand
// focus back to the opener (the Settings button) on close.
useEffect(() => {
const trigger = document.activeElement as HTMLElement | null;
titleRef.current?.focus();
const restoreBackground = screenRef.current ? inertOutside(screenRef.current) : undefined;
return () => {
restoreBackground?.();
if (trigger && trigger.isConnected) trigger.focus();
};
}, []);
// The form's controls unmount when it closes; hand focus to Add child then
// (after the render that re-enables it).
useEffect(() => {
if (formWasOpen.current && form === null) addRef.current?.focus();
formWasOpen.current = form !== null;
}, [form]);
useEffect(() => {
let cancelled = false;
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
fetchChildren()
.then((children) => {
if (!cancelled) setState({ kind: "ready", children: sortChildren(children) });
})
.catch((error: unknown) => {
if (cancelled) return;
setState({ kind: "error", message: errorMessage(error, "Children could not be loaded") });
});
return () => {
cancelled = true;
};
}, [attempt]);
useEffect(() => {
let cancelled = false;
setPreferences({ kind: "loading" });
fetchSettings()
.then((settings) => {
if (!cancelled) setPreferences({ kind: "ready", settings });
})
.catch((error: unknown) => {
if (cancelled) return;
setPreferences({ kind: "error", message: errorMessage(error, "Preferences could not be loaded") });
});
return () => {
cancelled = true;
};
}, [preferencesAttempt]);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!inFlight.current) onClose();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
/**
* Run one write, then refetch the list. One write at a time; a failure
* keeps the screen (and any open form) as it was and says why.
*/
async function run(key: string, write: () => Promise<unknown>, action: string): Promise<boolean> {
if (inFlight.current) return false;
inFlight.current = true;
setBusy(key);
setActionError(null);
setFormError(null);
try {
await write();
} catch (error) {
const message = errorMessage(error, action);
if (key === "form") setFormError(message);
else setActionError(message);
inFlight.current = false;
setBusy(null);
return false;
}
try {
const children = await fetchChildren();
setState({ kind: "ready", children: sortChildren(children) });
} catch (error) {
setActionError(errorMessage(error, "Saved, but the children list could not be refreshed"));
}
inFlight.current = false;
setBusy(null);
return true;
}
function closeForm() {
setForm(null);
setFormError(null);
}
async function submitForm(body: ChildCreateBody | ChildEditBody | string) {
if (form === null) return;
if (typeof body === "string") {
setFormError(body);
return;
}
const saved =
form === "new"
? await run("form", () => createChild(body as ChildCreateBody), "The child could not be added")
: await run("form", () => updateChild(form.id, body), "The child could not be saved");
if (saved) closeForm();
}
async function savePreferences(changes: SettingsChanges | string) {
if (typeof changes === "string") {
setPreferencesMessage({ kind: changes === "No changes to save." ? "info" : "error", text: changes });
return;
}
if (inFlight.current) return;
inFlight.current = true;
setBusy("preferences");
setPreferencesMessage(null);
try {
const settings = await updateSettings(changes);
setPreferences({ kind: "ready", settings });
setPreferencesMessage({ kind: "info", text: "Preferences saved." });
} catch (error) {
setPreferencesMessage({
kind: "error",
text: errorMessage(error, "The preferences could not be saved"),
});
}
inFlight.current = false;
setBusy(null);
}
function move(children: AdminChild[], index: number, delta: -1 | 1) {
const ids = children.map((c) => c.id);
const target = index + delta;
[ids[index], ids[target]] = [ids[target], ids[index]];
void run(`move-${children[index].id}`, () => reorderChildren(ids), "The order could not be saved");
}
function toggleActive(child: AdminChild) {
void run(
`active-${child.id}`,
() => setChildActive(child.id, !child.is_active),
child.is_active ? "The child could not be deactivated" : "The child could not be reactivated",
);
}
let body;
if (state.kind === "loading") {
body = (
<div className="settings-status" role="status" data-testid="settings-loading">
Loading children…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="settings-status settings-status--error" role="alert" data-testid="settings-error">
<p>{state.message}</p>
<button type="button" className="settings-action" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { children } = state;
body = (
<>
{children.length > 0 ? (
<ul className="settings-card settings-list" aria-label="Children">
{children.map((child, index) => (
<ChildRow
key={child.id}
child={child}
index={index}
count={children.length}
busy={busy}
onEdit={() => {
setFormError(null);
setForm(child);
}}
onMove={(delta) => move(children, index, delta)}
onToggleActive={() => toggleActive(child)}
/>
))}
</ul>
) : (
<div className="settings-card settings-empty">No children yet.</div>
)}
{form !== null ? (
<ChildForm
key={form === "new" ? "new" : form.id}
target={form}
siblings={children}
saving={busy === "form"}
error={formError}
onCancel={closeForm}
onSubmit={(b) => void submitForm(b)}
/>
) : null}
</>
);
}
return (
<div
ref={screenRef}
className="settings-screen"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
onKeyDown={onKeyDown}
>
<header className="settings-header">
<button
type="button"
className="settings-back"
aria-label="Back"
disabled={busy !== null}
onClick={onClose}
>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="settings-title" id="settings-title" ref={titleRef} tabIndex={-1}>
Settings
</h2>
<p className="settings-sub">Household setup</p>
</div>
</header>
<div
className="settings-scroll"
data-testid="settings-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
<div className="settings-section-head">
<h3 className="settings-section-label">Children</h3>
<button
ref={addRef}
type="button"
className="settings-add"
disabled={state.kind !== "ready" || busy !== null || form === "new"}
onClick={() => {
setFormError(null);
setForm("new");
}}
>
<PlusGlyph />
Add child
</button>
</div>
{actionError !== null ? (
<div className="settings-action-error" role="alert" data-testid="settings-action-error">
<span>{actionError}</span>
<button type="button" className="settings-action" onClick={() => setActionError(null)}>
Dismiss
</button>
</div>
) : null}
{body}
<div className="settings-section-head">
<h3 className="settings-section-label">Preferences</h3>
</div>
{preferences.kind === "loading" ? (
<div className="settings-status" role="status" data-testid="preferences-loading">
Loading preferences…
</div>
) : preferences.kind === "error" ? (
<div
className="settings-status settings-status--error"
role="alert"
data-testid="preferences-error"
>
<p>{preferences.message}</p>
<button
type="button"
className="settings-action"
onClick={() => setPreferencesAttempt((n) => n + 1)}
>
Try again
</button>
</div>
) : (
<PreferencesForm
// A saved response remounts the form so its draft matches the store.
key={JSON.stringify(preferences.settings)}
settings={preferences.settings}
saving={busy === "preferences"}
locked={busy !== null && busy !== "preferences"}
message={preferencesMessage}
onSubmit={(changes) => void savePreferences(changes)}
/>
)}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Inline stroke glyphs for the Settings screen (Lucide-style 24px grid),
* CDN-free like the tabs' glyphs.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
}
export function ArrowUpGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m5 12 7-7 7 7M12 19V5" />
</Glyph>
);
}
export function ArrowDownGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M19 12l-7 7-7-7" />
</Glyph>
);
}
export function PlusGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M12 5v14M5 12h14" />
</Glyph>
);
}
+22 -4
View File
@@ -12,6 +12,7 @@ import { ApiForbiddenError } from "../api/client";
import { ApiRequestError } from "../api/definitions"; import { ApiRequestError } from "../api/definitions";
import { uncompleteInstance } from "../api/instances"; import { uncompleteInstance } from "../api/instances";
import { useTransitions } from "../api/useTransitions"; import { useTransitions } from "../api/useTransitions";
import SettingsScreen from "../settings/SettingsScreen";
import { import {
formatClockTime, formatClockTime,
formatDueTime, formatDueTime,
@@ -43,15 +44,22 @@ function isOverdueOpen(instance: SnapshotInstance): boolean {
return instance.overdue && instance.state === "open"; return instance.overdue && instance.state === "open";
} }
function ScreenHeader({ sub }: { sub: string }) { function ScreenHeader({ sub, onSettings }: { sub: string; onSettings: () => void }) {
return ( return (
<header className="today-header"> <header className="today-header">
<div> <div>
<h2 className="today-title">Today</h2> <h2 className="today-title">Today</h2>
<p className="today-sub">{sub}</p> <p className="today-sub">{sub}</p>
</div> </div>
{/* Settings screen is a later task; the control is not wired yet. */} {/* Opens the Settings screen. Only its Children section exists so far.
<button type="button" className="today-icon-button" aria-label="Settings"> TODO: horizon days, day rollover time and notification config are a
separate later task. */}
<button
type="button"
className="today-icon-button"
aria-label="Settings"
onClick={onSettings}
>
<SettingsGlyph /> <SettingsGlyph />
</button> </button>
</header> </header>
@@ -221,6 +229,7 @@ export default function TodayTab() {
const [undoingId, setUndoingId] = useState<number | null>(null); const [undoingId, setUndoingId] = useState<number | null>(null);
const undoingRef = useRef(false); const undoingRef = useRef(false);
const [undoError, setUndoError] = useState<string | null>(null); const [undoError, setUndoError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
function refresh() { function refresh() {
silentNext.current = true; silentNext.current = true;
@@ -326,7 +335,7 @@ export default function TodayTab() {
return ( return (
<div className="today-screen"> <div className="today-screen">
<ScreenHeader sub={sub} /> <ScreenHeader sub={sub} onSettings={() => setSettingsOpen(true)} />
<div <div
className="today-scroll" className="today-scroll"
data-testid="today-scroll" data-testid="today-scroll"
@@ -342,6 +351,15 @@ export default function TodayTab() {
) : null} ) : null}
{body} {body}
</div> </div>
{settingsOpen ? (
<SettingsScreen
onClose={() => {
setSettingsOpen(false);
// Child names and order feed the snapshot.
refresh();
}}
/>
) : null}
</div> </div>
); );
} }
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues", "issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [], "requirements": [],
"version": "0.7.0" "version": "0.7.1"
} }
+11
View File
@@ -21,3 +21,14 @@ NESTQUEST_OIDC_JWKS_URL=
# unset; on the API box set the LAN address the reverse proxy # unset; on the API box set the LAN address the reverse proxy
# forwards to, e.g. 10.60.1.14. # forwards to, e.g. 10.60.1.14.
NESTQUEST_BIND_ADDRESS= NESTQUEST_BIND_ADDRESS=
# Admin PWA build args (compose interpolation into the pwa service's
# VITE_* build args; baked into the bundle, so rebuild after changing).
# PUBLIC values, not secrets: the Authentik application is a PKCE client
# with no client secret. All three are required — the pwa image build
# fails when any is empty.
# NESTQUEST_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
# NESTQUEST_API_BASE_URL=https://nestquest.cubecraftlabs.com
NESTQUEST_AUTHENTIK_CLIENT_ID=
NESTQUEST_AUTHENTIK_ISSUER=
NESTQUEST_API_BASE_URL=
+110 -12
View File
@@ -8,7 +8,7 @@ given, never their content.
| Piece | Where | Real value | | Piece | Where | Real value |
| --- | --- | --- | | --- | --- | --- |
| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` | | API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` (API), `8081` (admin PWA, section 10) |
| Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` | | Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` |
| Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` | | Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` |
| Identity | Authentik | `https://auth.cubecraftlabs.com` | | Identity | Authentik | `https://auth.cubecraftlabs.com` |
@@ -57,12 +57,13 @@ section 5 depend on uid 999):
## 2. Compose ## 2. Compose
`deploy/compose.yaml` defines two services and one named volume: `deploy/compose.yaml` defines three services and one named volume:
| Service | Image | Role | | Service | Image | Role |
| --- | --- | --- | | --- | --- | --- |
| `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` | | `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` |
| `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy | | `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy |
| `pwa` | `nestquest-pwa:latest` (built from `deploy/pwa`, section 10) | Admin PWA served by nginx on `8081`; independent of the other two |
- **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both - **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both
services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db` services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db`
@@ -96,14 +97,18 @@ repository. What it must contain:
| Router | Rule | Middleware | Service | | Router | Rule | Middleware | Service |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| panel | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `http://10.60.1.14:8080` | | `nestquest-panel` (priority 200) | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `nestquest-panel-lan`: `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `nestquest-api` → `http://10.60.1.14:8080` |
| admin (everything else: `/health`, `/docs`, `/openapi.json`, `/api/v1/admin/*`) | ``Host(`nestquest.cubecraftlabs.com`)`` | none — public; the API itself enforces Authentik JWTs (section 4) | `http://10.60.1.14:8080` | | `nestquest-admin` (priority 100: `/api/v1/admin`, `/health`, `/openapi.json`, `/docs`) | ``Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) \|\| Path(`/health`) \|\| Path(`/openapi.json`) \|\| PathPrefix(`/docs`))`` | `nestquest-headers` — no source restriction; the API itself enforces Authentik JWTs (section 4) | `nestquest-api` → `http://10.60.1.14:8080` |
| `nestquest-app` (priority 50: everything else — the admin PWA) | see section 10 | none at Traefik; nginx allow-lists the proxy | `nestquest-pwa` → `http://10.60.1.14:8081` |
Both routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver` All routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver`
(HTTP is redirected to HTTPS by the shared Traefik). The panel router's (HTTP is redirected to HTTPS by the shared Traefik). Priorities are set
rule is longer, so Traefik's default rule-length priority evaluates it explicitly so the order never depends on rule length: panel (200) above
before the catch-all admin router; if you set explicit priorities, keep admin (100), and the PWA's `nestquest-app` router (50) below both. The
panel higher. admin router is scoped to `/api/v1/admin`, `/health`, `/openapi.json`
and `/docs` — a bare ``Host(...)`` rule at a higher priority would
swallow `/` and every PWA route. Any other `/api/*` path matches no
router (the app router excludes `/api`), so Traefik answers `404`.
Reference shape, to rebuild the file if it is ever lost (entrypoint names Reference shape, to rebuild the file if it is ever lost (entrypoint names
must match the shared Traefik's static config on nukapi-03): must match the shared Traefik's static config on nukapi-03):
@@ -113,26 +118,42 @@ http:
routers: routers:
nestquest-panel: nestquest-panel:
rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)" rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)"
priority: 200
entryPoints: [websecure] entryPoints: [websecure]
middlewares: [nestquest-panel-allowlist] middlewares: [nestquest-panel-lan]
service: nestquest-api service: nestquest-api
tls: tls:
certResolver: myresolver certResolver: myresolver
nestquest-admin: nestquest-admin:
rule: "Host(`nestquest.cubecraftlabs.com`)" rule: "Host(`nestquest.cubecraftlabs.com`) && (PathPrefix(`/api/v1/admin`) || Path(`/health`) || Path(`/openapi.json`) || PathPrefix(`/docs`))"
priority: 100
entryPoints: [websecure] entryPoints: [websecure]
middlewares: [nestquest-headers]
service: nestquest-api service: nestquest-api
tls: tls:
certResolver: myresolver certResolver: myresolver
nestquest-app:
rule: "Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)"
priority: 50
entryPoints: [websecure]
service: nestquest-pwa
tls:
certResolver: myresolver
middlewares: middlewares:
nestquest-panel-allowlist: nestquest-panel-lan:
ipAllowList: ipAllowList:
sourceRange: ["10.60.1.80/32"] sourceRange: ["10.60.1.80/32"]
nestquest-headers:
# headers middleware; copy its definition from the live file
services: services:
nestquest-api: nestquest-api:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://10.60.1.14:8080" - url: "http://10.60.1.14:8080"
nestquest-pwa:
loadBalancer:
servers:
- url: "http://10.60.1.14:8081"
``` ```
**Certificates.** `myresolver` is the shared Traefik's ACME (Let's **Certificates.** `myresolver` is the shared Traefik's ACME (Let's
@@ -453,3 +474,80 @@ To obtain `$ADMIN_JWT`: sign in to the admin PWA as the user, open the
browser developer tools, and copy the session-storage value browser developer tools, and copy the session-storage value
`nestquest.access_token`. Tokens are short-lived and are credentials — `nestquest.access_token`. Tokens are short-lived and are credentials —
do not store them. do not store them.
## 10. Admin PWA hosting
The admin PWA (`admin/`, a Vite + React single-page app) is served from
the API box by the compose service **`pwa`**: nginx on container port
`80`, published on `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081` (on the API
box `10.60.1.14:8081`). Traefik sends every path of
`nestquest.cubecraftlabs.com` that is not the API to it.
**Image** — `deploy/pwa/Dockerfile`, built from the **repository root**
(it needs `admin/`); `deploy/pwa/Dockerfile.dockerignore` limits the
context to `admin/` (without `node_modules`, `dist` or `.env` files) and
`deploy/pwa/nginx.conf`.
- Stage 1, `node:22-alpine` (pinned by digest): `npm ci`, then
`npm run build` with the three `VITE_*` build args below. Vite bakes
them into the bundle, so **changing any of them needs a rebuild**. The
build fails if any is empty.
- Stage 2, `nginx:alpine` pinned to
`sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f`
(nginx 1.31.6): the built `dist/` in `/usr/share/nginx/html` and
`deploy/pwa/nginx.conf` as `conf.d/default.conf`.
**Build args** — compose reads them from `deploy/.env` (documented in
`deploy/.env.example`). They are **public values, not secrets**: the
Authentik application is a PKCE client with no client secret.
| `deploy/.env` variable | Build arg | Production value |
| --- | --- | --- |
| `NESTQUEST_AUTHENTIK_CLIENT_ID` | `VITE_AUTHENTIK_CLIENT_ID` | client id of the Authentik NestQuest application (Authentik admin → Applications → Providers) |
| `NESTQUEST_AUTHENTIK_ISSUER` | `VITE_AUTHENTIK_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` |
| `NESTQUEST_API_BASE_URL` | `VITE_API_BASE_URL` | `https://nestquest.cubecraftlabs.com` |
The Authentik application's redirect URI must include
`https://nestquest.cubecraftlabs.com/auth/callback`.
**nginx** (`deploy/pwa/nginx.conf`):
- `allow 10.60.1.33; allow 10.60.1.14; deny all;` — only Traefik on
nukapi-03 and the API box itself get content; any other source gets
`403`, even with the port reachable on the LAN. Checks run on the API
box must use its LAN address (`10.60.1.14:8081`), not `127.0.0.1`.
- SPA fallback: unknown paths (e.g. `/auth/callback`) serve `index.html`;
`/assets/*` (content-hashed, cached for a year) returns a real `404`
when missing.
- `/sw.js` and `/manifest.webmanifest` (`application/manifest+json`) are
served from the site root with `Cache-Control: no-cache`, as is
`index.html`, so a new release's service worker is picked up.
Build and start (the `api` and `litestream` services are unaffected):
dc build pwa
dc up -d pwa
dc ps pwa # "Up"
**Traefik router** — in `config/dynamic/nestquest-ccl.yml` on nukapi-03
(reference shape in section 3):
| Router | Rule | Priority | Service |
| --- | --- | --- | --- |
| `nestquest-app` | ``Host(`nestquest.cubecraftlabs.com`) && !PathPrefix(`/api`) && !Path(`/health`) && !Path(`/openapi.json`) && !PathPrefix(`/docs`)`` | `50` — below `nestquest-admin` (100) and `nestquest-panel` (200) | `nestquest-pwa` → `http://10.60.1.14:8081` |
Same `websecure` entrypoint and `myresolver` certificate as the API
routers; no Traefik middleware (nginx enforces the source allow-list).
The file provider reloads the dynamic file on save.
**Verification** (in addition to section 9):
| # | Check | Command | Expect |
| --- | --- | --- | --- |
| 1 | PWA root over TLS | `curl -s -w '\n%{http_code}\n' https://nestquest.cubecraftlabs.com/ \| tail -3` | `200`, the `NestQuest Admin` shell |
| 2 | Client route fallback | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/auth/callback` | `200 text/html` |
| 3 | Manifest and worker | `curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://nestquest.cubecraftlabs.com/manifest.webmanifest` and the same for `/sw.js` | `200 application/manifest+json`, `200 application/javascript` |
| 4 | API still routed to the API | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` (`{"status":"ok"}`, not HTML) |
| 5 | Panel refused externally | section 9 check 4 | `403` |
| 6 | Non-Traefik source refused | from a LAN machine other than `10.60.1.33`/`10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `403` (nginx `deny all`) |
| 7 | Proxy source allowed | on `10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `200` |
+19
View File
@@ -42,6 +42,25 @@ services:
retries: 5 retries: 5
start_period: 15s start_period: 15s
# Admin PWA (Feature 24): the Vite bundle from admin/ served by nginx
# (deploy/pwa/). Traefik routes every non-API path of the public host
# here on port 8081; nginx itself only accepts the proxy (10.60.1.33)
# and this host (10.60.1.14). The three build args are PUBLIC values
# (the OIDC client is a PKCE client, no secret) read from deploy/.env
# by compose interpolation; the image build fails if any is empty.
pwa:
build:
context: ..
dockerfile: deploy/pwa/Dockerfile
args:
VITE_AUTHENTIK_CLIENT_ID: ${NESTQUEST_AUTHENTIK_CLIENT_ID:-}
VITE_AUTHENTIK_ISSUER: ${NESTQUEST_AUTHENTIK_ISSUER:-}
VITE_API_BASE_URL: ${NESTQUEST_API_BASE_URL:-}
image: nestquest-pwa:latest
restart: unless-stopped
ports:
- "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8081:80"
# Continuous backup: streams the SQLite database on the shared named # Continuous backup: streams the SQLite database on the shared named
# volume to the Synology NAS over SFTP (config: deploy/litestream.yml). # volume to the Synology NAS over SFTP (config: deploy/litestream.yml).
# The SFTP private key deploy/litestream_key is git-ignored and must be # The SFTP private key deploy/litestream_key is git-ignored and must be
+29
View File
@@ -0,0 +1,29 @@
# Admin PWA image (Feature 24): builds the Vite bundle from admin/ and
# serves it with nginx. Build context is the REPOSITORY ROOT (it needs
# admin/); deploy/pwa/Dockerfile.dockerignore limits that context to
# admin/ and this directory's nginx.conf.
#
# docker compose -f deploy/compose.yaml build pwa
#
# The VITE_* values are baked into the bundle at build time. None is a
# secret (the OIDC client is a public PKCE client), and all three are
# required: the build fails fast rather than relying on app defaults.
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build
ARG VITE_AUTHENTIK_CLIENT_ID
ARG VITE_AUTHENTIK_ISSUER
ARG VITE_API_BASE_URL
WORKDIR /src/admin
COPY admin/package.json admin/package-lock.json ./
RUN npm ci
COPY admin/ ./
RUN test -n "$VITE_AUTHENTIK_CLIENT_ID" && test -n "$VITE_AUTHENTIK_ISSUER" && test -n "$VITE_API_BASE_URL" \
|| { echo "VITE_AUTHENTIK_CLIENT_ID, VITE_AUTHENTIK_ISSUER and VITE_API_BASE_URL build args are required" >&2; exit 1; }
RUN npm run build
FROM nginx:alpine@sha256:1ed1b0e1d7652937d6cbdaf4018c7b6fc009a7dd6c3047351e2eddda745de43f
COPY deploy/pwa/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/admin/dist/ /usr/share/nginx/html/
+11
View File
@@ -0,0 +1,11 @@
# Build-context filter for deploy/pwa/Dockerfile (BuildKit reads
# <Dockerfile>.dockerignore in place of the root .dockerignore, which
# excludes deploy/). Only admin/ sources and nginx.conf enter the
# build; local dependencies, build output and .env files never do.
*
!admin/
!deploy/pwa/nginx.conf
admin/node_modules
admin/dist
**/.env
**/.env.*
+53
View File
@@ -0,0 +1,53 @@
# nginx server for the NestQuest admin PWA (Feature 24). Replaces the
# image's /etc/nginx/conf.d/default.conf. The shared Traefik on
# nukapi-03 (10.60.1.33) terminates TLS for nestquest.cubecraftlabs.com
# and forwards every path that is not the API (/api, /health,
# /openapi.json, /docs) here; see deploy/RUNBOOK.md section 10.
server {
listen 80;
server_name _;
# Only the reverse proxy and the API box itself may connect; every
# other source is refused (403) even if the port is reachable.
allow 10.60.1.33;
allow 10.60.1.14;
deny all;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
# Content-hashed bundle files: cache forever, and a missing asset is
# a real 404 rather than the SPA shell.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# The service worker must be revalidated on every load so a new
# release's worker (fresh revision, see admin/vite-sw-plugin.ts) is
# picked up.
location = /sw.js {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
# nginx's stock mime.types has no .webmanifest entry.
location = /manifest.webmanifest {
types { }
default_type application/manifest+json;
try_files $uri =404;
add_header Cache-Control "no-cache";
}
# SPA fallback: client routes such as /auth/callback resolve to the
# app shell, which is never cached so it always names the current
# hashed assets.
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nestquest" name = "nestquest"
version = "0.7.0" version = "0.7.1"
description = "NestQuest Home Assistant Integration" description = "NestQuest Home Assistant Integration"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]] [[package]]
name = "nestquest" name = "nestquest"
version = "0.7.0" version = "0.7.1"
source = { virtual = "." } source = { virtual = "." }
[package.dev-dependencies] [package.dev-dependencies]