Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16794d8fc | ||
|
|
934c788f30 | ||
|
|
fb432fda5a | ||
|
|
75ad9892b8 | ||
|
|
48d0c4ef86 |
@@ -0,0 +1,13 @@
|
||||
# NestQuest Admin PWA — runtime configuration (Vite env).
|
||||
# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
|
||||
|
||||
# Authentik OIDC provider. Defaults to the issuer below when unset.
|
||||
VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
|
||||
|
||||
# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
|
||||
# No default: the app shows a configuration error screen when this is missing.
|
||||
# Never set a client_secret — this is a browser SPA using PKCE only.
|
||||
VITE_AUTHENTIK_CLIENT_ID=
|
||||
|
||||
# NestQuest API base URL.
|
||||
VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#3b3ab8" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>NestQuest Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nestquest-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "NestQuest Admin",
|
||||
"short_name": "NestQuest",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4f4f6",
|
||||
"theme_color": "#3b3ab8",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* NestQuest Admin service worker — hand-written, served from the site root.
|
||||
Precaches the app shell, serves static assets cache-first, and falls back
|
||||
to the cached shell for navigations when offline. Only same-origin GET
|
||||
requests are handled; cross-origin requests (Authentik, the API host) are
|
||||
never intercepted or cached.
|
||||
The build (vite-sw-plugin.ts) replaces PRECACHE_ASSETS with the hashed
|
||||
bundles referenced by index.html and the revision marker with a content
|
||||
hash, so each release installs a new worker with fresh caches. */
|
||||
|
||||
const REVISION = "__NQ_BUILD_REVISION__";
|
||||
const CACHE_PREFIX = "nestquest-admin-";
|
||||
const SHELL_CACHE = `${CACHE_PREFIX}shell-${REVISION}`;
|
||||
const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${REVISION}`;
|
||||
const CURRENT_CACHES = [SHELL_CACHE, RUNTIME_CACHE];
|
||||
|
||||
const PRECACHE_ASSETS = [];
|
||||
|
||||
const SHELL_URLS = [
|
||||
...new Set([
|
||||
"/",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
"/icons/icon-maskable-512.png",
|
||||
...PRECACHE_ASSETS,
|
||||
]),
|
||||
];
|
||||
|
||||
// Request destinations treated as static assets (cache-first). Anything else
|
||||
// same-origin (e.g. API calls) goes straight to the network.
|
||||
const STATIC_DESTINATIONS = ["script", "style", "image", "font", "manifest"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(SHELL_URLS))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(CACHE_PREFIX) && !CURRENT_CACHES.includes(key))
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== "GET") return;
|
||||
if (new URL(request.url).origin !== self.location.origin) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() =>
|
||||
caches.match("/index.html", { cacheName: SHELL_CACHE }).then(
|
||||
(cached) => cached || Response.error(),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (STATIC_DESTINATIONS.includes(request.destination)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok && response.type === "basic") {
|
||||
const cache = await caches.open(RUNTIME_CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
|
||||
// Pure Node (zlib only) — no network, no image dependencies.
|
||||
// Run: node scripts/generate-icons.mjs
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
|
||||
const BLUE = [0x3b, 0x3a, 0xb8];
|
||||
const PURPLE = [0xa0, 0x35, 0xcc];
|
||||
const WHITE = [0xff, 0xff, 0xff];
|
||||
|
||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body));
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function encodePng(size, pixel) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // colour type: RGB
|
||||
const raw = Buffer.alloc(size * (size * 3 + 1));
|
||||
for (let y = 0; y < size; y++) {
|
||||
const row = y * (size * 3 + 1);
|
||||
raw[row] = 0; // filter: none
|
||||
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
|
||||
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
|
||||
// safe zone (inner 80% circle).
|
||||
function brandMark(size, scale) {
|
||||
const c = size / 2;
|
||||
const outer = size * 0.34 * scale;
|
||||
const inner = size * 0.24 * scale;
|
||||
const dot = size * 0.1 * scale;
|
||||
return (x, y) => {
|
||||
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
||||
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
|
||||
const t = (x + y) / (2 * (size - 1));
|
||||
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
|
||||
};
|
||||
}
|
||||
|
||||
const outDir = new URL("../public/icons/", import.meta.url);
|
||||
const icons = [
|
||||
["icon-192.png", 192, 1],
|
||||
["icon-512.png", 512, 1],
|
||||
["icon-maskable-512.png", 512, 0.75],
|
||||
];
|
||||
for (const [name, size, scale] of icons) {
|
||||
const path = fileURLToPath(new URL(name, outDir));
|
||||
writeFileSync(path, encodePng(size, brandMark(size, scale)));
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* Shell layout — mobile-first (390x844 reference), plain CubeCraft styling. */
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.admin-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-subline {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
flex: 1;
|
||||
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
|
||||
}
|
||||
|
||||
.admin-placeholder {
|
||||
margin: 0;
|
||||
padding: 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.admin-tabbar {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
display: flex;
|
||||
min-height: calc(var(--nq-a-tabbar-height) + 12px);
|
||||
padding: 8px 4px calc(12px + env(safe-area-inset-bottom));
|
||||
background: var(--nq-a-surface);
|
||||
border-top: 1px solid var(--nq-a-border);
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
font-weight: 700;
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
/* Auth screens (login, errors, refusal). */
|
||||
|
||||
.admin-login,
|
||||
.admin-error,
|
||||
.admin-refusal,
|
||||
.admin-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 100%;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-login-button {
|
||||
margin-top: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--nq-a-accent, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-error,
|
||||
.admin-refusal {
|
||||
color: var(--nq-a-ink, inherit);
|
||||
}
|
||||
|
||||
.admin-refusal h1,
|
||||
.admin-error h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-refusal p,
|
||||
.admin-error p {
|
||||
margin: 0;
|
||||
max-width: 34em;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
describe("NestQuest Admin shell", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders the title and all four tab labels", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts on the Today placeholder and switches on tab click", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("tabpanel").textContent).toContain(
|
||||
"Today placeholder",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
|
||||
expect(screen.getByRole("tabpanel").textContent).toContain(
|
||||
"History placeholder",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
|
||||
const TABS = [
|
||||
{ id: "today", label: "Today" },
|
||||
{ id: "tasks", label: "Tasks" },
|
||||
{ id: "schedule", label: "Schedule" },
|
||||
{ id: "history", label: "History" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export default function App() {
|
||||
const [active, setActive] = useState<TabId>("today");
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-header">
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Admin console</p>
|
||||
</header>
|
||||
<main
|
||||
className="admin-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
<p className="admin-placeholder">
|
||||
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||
arrive in later tasks.
|
||||
</p>
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab.id === active}
|
||||
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
||||
onClick={() => setActive(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
import { storeTokens } from "./auth/oidc";
|
||||
import { config } from "./config";
|
||||
|
||||
// The bundled config reads real env at import time; seed the shared module's
|
||||
// clientId directly so AppRoutes sees a configured app in these tests.
|
||||
(config as { clientId: string | undefined }).clientId = "test-client-id";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("AppRoutes auth gating", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
it("renders the login screen when unauthenticated at /", () => {
|
||||
render(<AppRoutes />);
|
||||
expect(screen.getByTestId("login-screen")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /sign in with authentik/i }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the four-tab shell after a successful authenticated probe", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
|
||||
});
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the explicit refusal when the API answers 403", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<AppRoutes />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the configuration error screen when the client id is missing", () => {
|
||||
const original = config.clientId;
|
||||
(config as { clientId: string | undefined }).clientId = undefined;
|
||||
try {
|
||||
render(<AppRoutes />);
|
||||
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
|
||||
} finally {
|
||||
(config as { clientId: string | undefined }).clientId = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Minimal path-based routing without react-router-dom: the app only needs two
|
||||
* paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
|
||||
* enough and keeps the bundle small. Change screens listen to popstate.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConfigError, config } from "./config";
|
||||
import {
|
||||
AuthError,
|
||||
buildAuthorizeUrl,
|
||||
clearAuth,
|
||||
handleCallback,
|
||||
isAuthenticated,
|
||||
} from "./auth/oidc";
|
||||
import { ApiForbiddenError, apiFetch } from "./api/client";
|
||||
import AdminShell from "./App";
|
||||
|
||||
type Screen =
|
||||
| { kind: "loading" }
|
||||
| { kind: "login" }
|
||||
| { kind: "config-error"; message: string }
|
||||
| { kind: "auth-error"; message: string }
|
||||
| { kind: "refusal"; message: string }
|
||||
| { kind: "app" };
|
||||
|
||||
function currentPath(): string {
|
||||
return window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
async function probeApi(setScreen: (screen: Screen) => void) {
|
||||
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
|
||||
// right after login instead of a silent empty shell.
|
||||
try {
|
||||
await apiFetch("/api/v1/children", {}, config);
|
||||
setScreen({ kind: "app" });
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
setScreen({ kind: "config-error", message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof ApiForbiddenError) {
|
||||
setScreen({ kind: "refusal", message: error.message });
|
||||
return;
|
||||
}
|
||||
// Network or other failures still allow the shell to render; API screens
|
||||
// handle their own errors in later tasks.
|
||||
setScreen({ kind: "app" });
|
||||
}
|
||||
}
|
||||
|
||||
function LoginScreen({ onLogin }: { onLogin: () => void }) {
|
||||
return (
|
||||
<div className="admin-login" data-testid="login-screen">
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Sign in to continue</p>
|
||||
<button type="button" className="admin-login-button" onClick={onLogin}>
|
||||
Sign in with Authentik
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppRoutes() {
|
||||
const [screen, setScreen] = useState<Screen>({ kind: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
// Re-evaluate on navigation (popstate) — routing is path based.
|
||||
const onPop = () => setScreen({ kind: "loading" });
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const path = currentPath();
|
||||
|
||||
if (!config.clientId) {
|
||||
setScreen({
|
||||
kind: "config-error",
|
||||
message:
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/auth/callback") {
|
||||
(async () => {
|
||||
try {
|
||||
await handleCallback(window.location.search, config);
|
||||
if (cancelled) return;
|
||||
window.history.replaceState({}, "", "/");
|
||||
await probeApi((next) => !cancelled && setScreen(next));
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof AuthError || error instanceof Error
|
||||
? error.message
|
||||
: "Authentication failed.";
|
||||
setScreen({ kind: "auth-error", message });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (isAuthenticated()) {
|
||||
void probeApi((next) => !cancelled && setScreen(next));
|
||||
} else {
|
||||
setScreen({ kind: "login" });
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const startLogin = async () => {
|
||||
try {
|
||||
const url = await buildAuthorizeUrl(config);
|
||||
window.location.assign(url);
|
||||
} catch (error) {
|
||||
setScreen({
|
||||
kind: "config-error",
|
||||
message: error instanceof Error ? error.message : "Login failed.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (screen.kind) {
|
||||
case "loading":
|
||||
return (
|
||||
<div className="admin-loading" role="status" data-testid="loading-screen">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
case "login":
|
||||
return <LoginScreen onLogin={startLogin} />;
|
||||
case "config-error":
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="config-error-screen">
|
||||
<h1>Configuration error</h1>
|
||||
<p>{screen.message}</p>
|
||||
</div>
|
||||
);
|
||||
case "auth-error":
|
||||
return (
|
||||
<div className="admin-error" role="alert" data-testid="auth-error-screen">
|
||||
<h1>Sign-in failed</h1>
|
||||
<p>{screen.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
window.history.replaceState({}, "", "/");
|
||||
setScreen({ kind: "loading" });
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "refusal":
|
||||
return (
|
||||
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
|
||||
<h1>Access refused</h1>
|
||||
<p>{screen.message}</p>
|
||||
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case "app":
|
||||
return <AdminShell />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ApiForbiddenError,
|
||||
ApiUnauthorizedError,
|
||||
apiFetch,
|
||||
} from "./client";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import type { AppConfig } from "../config";
|
||||
|
||||
const app: AppConfig = {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("API client", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("attaches the Bearer token to API calls", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
|
||||
await apiFetch("/api/v1/children", {}, app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://api.example.com/api/v1/children");
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
|
||||
|
||||
await expect(
|
||||
apiFetch("/api/v1/children", {}, app, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ApiForbiddenError);
|
||||
});
|
||||
|
||||
it("triggers a login redirect on 401 with no refresh token", async () => {
|
||||
storeTokens({ access_token: "stale", expires_in: 600 });
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, assign });
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
|
||||
|
||||
await expect(
|
||||
apiFetch("/api/v1/children", {}, app, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ApiUnauthorizedError);
|
||||
expect(assign).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(assign.mock.calls[0][0] as string);
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://auth.example.com/application/o/authorize/",
|
||||
);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
|
||||
storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 500 })); // retry
|
||||
|
||||
const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
|
||||
expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
|
||||
// A server failure must surface as the real status, not ApiUnauthorizedError.
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Minimal fetch wrapper for the NestQuest API.
|
||||
*
|
||||
* - Attaches `Authorization: Bearer <access_token>` when a token is stored.
|
||||
* - 401: not authenticated (or token expired) -> trigger a new login.
|
||||
* - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
|
||||
* render an explicit refusal message.
|
||||
*/
|
||||
import { config, type AppConfig } from "../config";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
getToken,
|
||||
isExpired,
|
||||
refreshAccessToken,
|
||||
} from "../auth/oidc";
|
||||
|
||||
export class ApiForbiddenError extends Error {
|
||||
constructor(message = "Your account is not in the nestquest-admins group.") {
|
||||
super(message);
|
||||
this.name = "ApiForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiUnauthorizedError extends Error {
|
||||
constructor(message = "Not authenticated.") {
|
||||
super(message);
|
||||
this.name = "ApiUnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions extends Omit<RequestInit, "headers"> {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to Authentik to start a new login.
|
||||
* Exported for testing injection.
|
||||
*/
|
||||
export async function triggerLogin(app: AppConfig = config): Promise<void> {
|
||||
const url = await buildAuthorizeUrl(app);
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
export async function apiFetch(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<Response> {
|
||||
if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
|
||||
// token renewed; fall through and use the fresh one
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const headers = {
|
||||
...(options.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
|
||||
const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Not authenticated: either no token or an unusable one.
|
||||
if (await refreshAccessToken(app, fetchImpl)) {
|
||||
const retryToken = getToken();
|
||||
const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
|
||||
},
|
||||
});
|
||||
if (retryResponse.status === 403) throw new ApiForbiddenError();
|
||||
if (retryResponse.ok) return retryResponse;
|
||||
// Only 401 means "unauthorized"; preserve every other status (e.g. a
|
||||
// 500 from the API) so callers can react to the real response.
|
||||
if (retryResponse.status === 401) throw new ApiUnauthorizedError();
|
||||
return retryResponse;
|
||||
}
|
||||
await triggerLogin(app);
|
||||
throw new ApiUnauthorizedError();
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
// Authenticated, but Authentik's token does not belong to nestquest-admins.
|
||||
throw new ApiForbiddenError();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
clearAuth,
|
||||
getToken,
|
||||
handleCallback,
|
||||
logout,
|
||||
storeTokens,
|
||||
} from "./oidc";
|
||||
import type { AppConfig } from "../config";
|
||||
|
||||
function testApp(): AppConfig {
|
||||
return {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("OIDC flow", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
|
||||
const app = testApp();
|
||||
const url = new URL(await buildAuthorizeUrl(app));
|
||||
|
||||
expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("client_id")).toBe("test-client-id");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
|
||||
expect(url.searchParams.get("scope")).toBe("openid email profile");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
|
||||
url.searchParams.get("state"),
|
||||
);
|
||||
expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
|
||||
});
|
||||
|
||||
it("handleCallback rejects a mismatched state", async () => {
|
||||
await buildAuthorizeUrl(testApp());
|
||||
sessionStorage.setItem("nestquest.oidc_state", "different-state");
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
|
||||
).rejects.toThrow(/state mismatch/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
|
||||
await buildAuthorizeUrl(testApp());
|
||||
const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
|
||||
const state = sessionStorage.getItem("nestquest.oidc_state")!;
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
access_token: "at-123",
|
||||
refresh_token: "rt-456",
|
||||
id_token: "id-789",
|
||||
expires_in: 300,
|
||||
}),
|
||||
);
|
||||
await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://auth.example.com/application/o/token/");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
||||
|
||||
const body = new URLSearchParams(init.body);
|
||||
expect(body.get("grant_type")).toBe("authorization_code");
|
||||
expect(body.get("code")).toBe("abc");
|
||||
expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
|
||||
expect(body.get("client_id")).toBe("test-client-id");
|
||||
expect(body.get("code_verifier")).toBe(verifier);
|
||||
expect(body.has("client_secret")).toBe(false);
|
||||
|
||||
expect(getToken()).toBe("at-123");
|
||||
// one-time values consumed
|
||||
expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
|
||||
expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
|
||||
});
|
||||
|
||||
it("handleCallback surfaces provider errors", async () => {
|
||||
await expect(
|
||||
handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
|
||||
).rejects.toThrow(/nope|access_denied/);
|
||||
});
|
||||
|
||||
it("clearAuth removes all stored auth data", () => {
|
||||
storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
||||
clearAuth();
|
||||
expect(getToken()).toBeUndefined();
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("logout clears auth and redirects to the end-session endpoint", async () => {
|
||||
storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
|
||||
const assign = vi.fn();
|
||||
const original = window.location;
|
||||
vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
|
||||
|
||||
await logout(testApp());
|
||||
|
||||
expect(assign).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(assign.mock.calls[0][0] as string);
|
||||
expect(url.toString()).toBe(
|
||||
"https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
|
||||
);
|
||||
expect(getToken()).toBeUndefined();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
|
||||
const issuer = "https://sso.discovery.example/application/o/discovered";
|
||||
const discoveryDoc = {
|
||||
issuer,
|
||||
authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
|
||||
token_endpoint: "https://sso.discovery.example/application/o/token/",
|
||||
userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
|
||||
end_session_endpoint: `${issuer}/end-session/`,
|
||||
jwks_uri: `${issuer}/jwks/`,
|
||||
};
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
String(url).endsWith("/.well-known/openid-configuration")
|
||||
? jsonResponse(discoveryDoc)
|
||||
: jsonResponse({}),
|
||||
),
|
||||
);
|
||||
const app: AppConfig = {
|
||||
issuer,
|
||||
clientId: "test-client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
|
||||
|
||||
// The authorize endpoint comes from the discovery document — Authentik
|
||||
// serves it outside the app-specific issuer path, so it is NOT
|
||||
// `${issuer}/authorize/` and must never be a hardcoded production host.
|
||||
expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
|
||||
expect(url.searchParams.get("client_id")).toBe("test-client-id");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* OIDC authorization-code + PKCE flow for a PUBLIC client.
|
||||
*
|
||||
* Never sends a client_secret anywhere. Tokens are stored in sessionStorage
|
||||
* (per-tab, cleared on tab close) — never localStorage.
|
||||
*/
|
||||
import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
|
||||
import {
|
||||
computeCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from "./pkce";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
accessToken: "nestquest.access_token",
|
||||
refreshToken: "nestquest.refresh_token",
|
||||
expiresAt: "nestquest.expires_at",
|
||||
idToken: "nestquest.id_token",
|
||||
state: "nestquest.oidc_state",
|
||||
codeVerifier: "nestquest.code_verifier",
|
||||
} as const;
|
||||
|
||||
export class AuthError extends Error {}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
function makeScopes(): string {
|
||||
return "openid email profile";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Authentik authorization redirect URL, storing state + verifier in
|
||||
* sessionStorage so the callback can validate them. Endpoints are resolved
|
||||
* from the issuer's OIDC discovery document when not set on the config.
|
||||
*/
|
||||
export async function buildAuthorizeUrl(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
if (!app.clientId) {
|
||||
throw new AuthError(
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
);
|
||||
}
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
||||
|
||||
sessionStorage.setItem(STORAGE_KEYS.state, state);
|
||||
sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
|
||||
|
||||
const url = new URL(resolved.authorizeEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", app.clientId);
|
||||
url.searchParams.set("redirect_uri", app.redirectUri);
|
||||
url.searchParams.set("scope", makeScopes());
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the OAuth callback: validate state, exchange the code at the token
|
||||
* endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
|
||||
*/
|
||||
export async function handleCallback(
|
||||
query: URLSearchParams | string = window.location.search,
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const params =
|
||||
typeof query === "string" ? new URLSearchParams(query) : query;
|
||||
|
||||
const providerError = params.get("error");
|
||||
if (providerError) {
|
||||
throw new AuthError(
|
||||
params.get("error_description") || `Provider returned error: ${providerError}`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
if (!code || !state) {
|
||||
throw new AuthError("Callback is missing the code or state parameter.");
|
||||
}
|
||||
|
||||
const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
|
||||
const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
|
||||
if (!storedState || storedState !== state || !codeVerifier) {
|
||||
throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
|
||||
}
|
||||
|
||||
if (!app.clientId) {
|
||||
throw new AuthError(
|
||||
"Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
|
||||
);
|
||||
}
|
||||
|
||||
const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: app.redirectUri,
|
||||
client_id: app.clientId,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const response = await fetchImpl(resolved.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
|
||||
}
|
||||
const tokens = (await response.json()) as TokenResponse;
|
||||
if (!tokens.access_token) {
|
||||
throw new AuthError("Token response did not include an access token.");
|
||||
}
|
||||
|
||||
storeTokens(tokens);
|
||||
// One-time use values are consumed on success.
|
||||
sessionStorage.removeItem(STORAGE_KEYS.state);
|
||||
sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
|
||||
}
|
||||
|
||||
export function storeTokens(tokens: TokenResponse): void {
|
||||
sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
|
||||
if (tokens.refresh_token) {
|
||||
sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
|
||||
}
|
||||
if (tokens.id_token) {
|
||||
sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
|
||||
}
|
||||
if (typeof tokens.expires_in === "number") {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEYS.expiresAt,
|
||||
String(Date.now() + tokens.expires_in * 1000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function getIdToken(): string | undefined {
|
||||
return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return Boolean(getToken());
|
||||
}
|
||||
|
||||
/** True once the stored access token has passed its (approximate) expiry. */
|
||||
export function isExpired(): boolean {
|
||||
const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
|
||||
if (!expiresAt) return false;
|
||||
return Date.now() >= Number(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token with the refresh token grant. Returns false when
|
||||
* no refresh token is stored or the grant fails (caller should re-login).
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken || !app.clientId) return false;
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: app.clientId,
|
||||
});
|
||||
|
||||
const response = await fetchImpl(resolved.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const tokens = (await response.json()) as TokenResponse;
|
||||
if (!tokens.access_token) return false;
|
||||
storeTokens(tokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
for (const key of Object.values(STORAGE_KEYS)) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
|
||||
export async function logout(
|
||||
app: AppConfig = config,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const resolved = await resolveEndpoints(app, fetchImpl);
|
||||
const idTokenHint = getIdToken();
|
||||
clearAuth();
|
||||
const params = new URLSearchParams();
|
||||
if (idTokenHint) params.set("id_token_hint", idTokenHint);
|
||||
if (app.clientId) params.set("client_id", app.clientId);
|
||||
params.set("post_logout_redirect_uri", window.location.origin);
|
||||
window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
base64UrlEncode,
|
||||
computeCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
isLikelyValidVerifier,
|
||||
} from "./pkce";
|
||||
|
||||
describe("PKCE helpers", () => {
|
||||
it("base64UrlEncode has no padding and url-safe characters", () => {
|
||||
// bytes chosen to force "+" and "/" and padding in plain base64
|
||||
const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
|
||||
const encoded = base64UrlEncode(bytes);
|
||||
expect(encoded).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
it("computes the RFC 7636 appendix-B S256 vector", async () => {
|
||||
const verifier =
|
||||
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const challenge = await computeCodeChallenge(verifier);
|
||||
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||
});
|
||||
|
||||
it("generates verifiers with valid charset and length", () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const verifier = generateCodeVerifier();
|
||||
expect(isLikelyValidVerifier(verifier)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
|
||||
expect(isLikelyValidVerifier("short")).toBe(false);
|
||||
expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
|
||||
expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
|
||||
});
|
||||
|
||||
it("generates distinct random states", () => {
|
||||
expect(generateState()).not.toBe(generateState());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* PKCE (RFC 7636) helpers for a public SPA client.
|
||||
*
|
||||
* Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
|
||||
* served over https or http://localhost, otherwise crypto.subtle is
|
||||
* unavailable and login cannot work.
|
||||
*/
|
||||
|
||||
const VERIFIER_CHARSET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
||||
|
||||
/** base64url-encode bytes with NO padding. */
|
||||
export function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function randomBase64Url(byteLength: number): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random code_verifier: 43-128 chars, unreserved charset only.
|
||||
* 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
return randomBase64Url(32);
|
||||
}
|
||||
|
||||
/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
|
||||
export async function computeCodeChallenge(verifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(verifier),
|
||||
);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/** Random opaque state value for CSRF protection. */
|
||||
export function generateState(): string {
|
||||
return randomBase64Url(16);
|
||||
}
|
||||
|
||||
export function isLikelyValidVerifier(verifier: string): boolean {
|
||||
return (
|
||||
verifier.length >= 43 &&
|
||||
verifier.length <= 128 &&
|
||||
[...verifier].every((char) => VERIFIER_CHARSET.includes(char))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ConfigError,
|
||||
discoveryUrlForIssuer,
|
||||
readConfig,
|
||||
resolveEndpoints,
|
||||
resolveEndpointsFromDiscovery,
|
||||
type AppConfig,
|
||||
} from "./config";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
|
||||
// application-specific issuer path, end-session/jwks under it.
|
||||
function discoveryDocument(issuer: string) {
|
||||
const normalized = issuer.replace(/\/+$/, "");
|
||||
const origin = new URL(normalized).origin;
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${origin}/application/o/authorize/`,
|
||||
token_endpoint: `${origin}/application/o/token/`,
|
||||
userinfo_endpoint: `${origin}/application/o/userinfo/`,
|
||||
end_session_endpoint: `${normalized}/end-session/`,
|
||||
jwks_uri: `${normalized}/jwks/`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("config", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("readConfig keeps the production issuer as the default", () => {
|
||||
const cfg = readConfig({});
|
||||
expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
|
||||
expect(cfg.clientId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("readConfig normalizes a trailing slash off a custom issuer", () => {
|
||||
expect(
|
||||
readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
|
||||
.issuer,
|
||||
).toBe("https://sso.custom.example/application/o/custom");
|
||||
});
|
||||
|
||||
it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
|
||||
const cfg = readConfig({});
|
||||
expect(cfg.authorizeEndpoint).toBeUndefined();
|
||||
expect(cfg.tokenEndpoint).toBeUndefined();
|
||||
expect(cfg.userinfoEndpoint).toBeUndefined();
|
||||
expect(cfg.endSessionEndpoint).toBeUndefined();
|
||||
expect(cfg.jwksUri).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the discovery URL for issuers with and without a trailing slash", () => {
|
||||
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
|
||||
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
|
||||
);
|
||||
expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
|
||||
"https://sso.example.com/application/o/app/.well-known/openid-configuration",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
|
||||
const issuer = "https://sso.other-host.example/application/o/other-app";
|
||||
const doc = discoveryDocument(issuer);
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
const app: AppConfig = {
|
||||
issuer,
|
||||
clientId: "client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
|
||||
);
|
||||
// Every endpoint comes verbatim from the (non-default) discovery document.
|
||||
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
|
||||
expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
|
||||
expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
|
||||
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
|
||||
expect(resolved.jwksUri).toBe(doc.jwks_uri);
|
||||
// Proves no hardcoded production host leaked into the resolved endpoints.
|
||||
for (const endpoint of [
|
||||
resolved.authorizeEndpoint,
|
||||
resolved.tokenEndpoint,
|
||||
resolved.userinfoEndpoint,
|
||||
resolved.endSessionEndpoint,
|
||||
resolved.jwksUri,
|
||||
]) {
|
||||
expect(endpoint).not.toMatch(/cubecraftlabs/);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves discovery for an issuer configured with a trailing slash", async () => {
|
||||
const issuer = "https://sso.slashy.example/application/o/slashy/";
|
||||
const doc = discoveryDocument(issuer);
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
|
||||
);
|
||||
expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
|
||||
expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
|
||||
});
|
||||
|
||||
it("caches discovery per issuer for the session", async () => {
|
||||
const issuer = "https://sso.cached.example/application/o/cached";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
|
||||
|
||||
const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
|
||||
const issuer = "https://sso.http-error.example/application/o/broken";
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
|
||||
|
||||
const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigError);
|
||||
expect((error as Error).message).toContain(issuer);
|
||||
expect((error as Error).message).toContain("HTTP 500");
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when discovery is unreachable", async () => {
|
||||
const issuer = "https://sso.unreachable.example/application/o/unreachable";
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toBeInstanceOf(ConfigError);
|
||||
});
|
||||
|
||||
it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
|
||||
const issuer = "https://sso.incomplete.example/application/o/incomplete";
|
||||
const doc = discoveryDocument(issuer) as Record<string, string>;
|
||||
delete doc.end_session_endpoint;
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toThrow(/end_session_endpoint/);
|
||||
});
|
||||
|
||||
it("rejects a discovery document advertising a different issuer", async () => {
|
||||
const issuer = "https://sso.mismatch.example/application/o/mismatch";
|
||||
const doc = {
|
||||
...discoveryDocument(issuer),
|
||||
issuer: "https://sso.elsewhere.example/application/o/elsewhere",
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
|
||||
|
||||
await expect(
|
||||
resolveEndpointsFromDiscovery(issuer, fetchMock as any),
|
||||
).rejects.toThrow(/issuer mismatch/i);
|
||||
});
|
||||
|
||||
it("the bundled config resolves its endpoints from discovery at runtime", async () => {
|
||||
const issuer = "https://sso.bundled.example/application/o/bundled";
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
|
||||
const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
|
||||
expect(bundled.authorizeEndpoint).toBeUndefined();
|
||||
|
||||
const resolved = await resolveEndpoints(bundled, fetchMock as any);
|
||||
|
||||
expect(resolved.authorizeEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/authorize/",
|
||||
);
|
||||
expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
|
||||
expect(resolved.userinfoEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/userinfo/",
|
||||
);
|
||||
expect(resolved.endSessionEndpoint).toBe(
|
||||
"https://sso.bundled.example/application/o/bundled/end-session/",
|
||||
);
|
||||
expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
|
||||
});
|
||||
|
||||
it("does not re-fetch discovery when the config already carries endpoints", async () => {
|
||||
const app: AppConfig = {
|
||||
issuer: "https://auth.example.com/application/o/nestquest",
|
||||
clientId: "client-id",
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
|
||||
tokenEndpoint: "https://auth.example.com/application/o/token/",
|
||||
userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
|
||||
endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
|
||||
redirectUri: "http://localhost:5173/auth/callback",
|
||||
};
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const resolved = await resolveEndpoints(app, fetchMock as any);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(resolved).toBe(app);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Runtime configuration, read from Vite env (import.meta.env).
|
||||
* See .env.example for the variables and their defaults.
|
||||
*
|
||||
* OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
|
||||
* issuer's OIDC discovery document (see resolveEndpoints), because Authentik
|
||||
* serves authorize/token/userinfo OUTSIDE the application-specific issuer
|
||||
* path (issuer is /application/o/<slug>/, authorize is /application/o/authorize/).
|
||||
* Both hardcoding the production host and naively concatenating onto the
|
||||
* issuer would break non-default issuers; discovery is the only correct way.
|
||||
*/
|
||||
|
||||
export interface AppConfig {
|
||||
issuer: string;
|
||||
clientId: string | undefined;
|
||||
apiBaseUrl: string;
|
||||
/** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
|
||||
authorizeEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
userinfoEndpoint?: string;
|
||||
endSessionEndpoint?: string;
|
||||
jwksUri?: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
|
||||
export type ResolvedAppConfig = AppConfig &
|
||||
Required<
|
||||
Pick<
|
||||
AppConfig,
|
||||
"authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
|
||||
>
|
||||
>;
|
||||
|
||||
export interface DiscoveredEndpoints {
|
||||
issuer: string;
|
||||
authorizeEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userinfoEndpoint: string;
|
||||
endSessionEndpoint: string;
|
||||
jwksUri: string;
|
||||
}
|
||||
|
||||
/** Raised when OIDC discovery fails or the configuration is unusable. */
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
const REQUIRED_ENDPOINT_NAMES = [
|
||||
"authorizeEndpoint",
|
||||
"tokenEndpoint",
|
||||
"userinfoEndpoint",
|
||||
"endSessionEndpoint",
|
||||
] as const;
|
||||
|
||||
export function readConfig(env: Record<string, string | undefined> = import.meta.env as any): AppConfig {
|
||||
const issuer = (
|
||||
env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
|
||||
).replace(/\/$/, "");
|
||||
const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
|
||||
|
||||
return {
|
||||
issuer,
|
||||
// No default: a missing client id must surface as a configuration error
|
||||
// screen, not a broken redirect to Authentik.
|
||||
clientId: clientId || undefined,
|
||||
apiBaseUrl,
|
||||
// authorize/token/userinfo/end-session endpoints are resolved from the
|
||||
// issuer's discovery document at runtime — never hardcoded here.
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
};
|
||||
}
|
||||
|
||||
export const config: AppConfig = readConfig();
|
||||
|
||||
interface DiscoveryDocument {
|
||||
issuer?: unknown;
|
||||
authorization_endpoint?: unknown;
|
||||
token_endpoint?: unknown;
|
||||
userinfo_endpoint?: unknown;
|
||||
end_session_endpoint?: unknown;
|
||||
jwks_uri?: unknown;
|
||||
}
|
||||
|
||||
function normalizeIssuer(issuer: string): string {
|
||||
return issuer.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Discovery document location for an issuer, tolerant of a trailing slash. */
|
||||
export function discoveryUrlForIssuer(issuer: string): string {
|
||||
return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
|
||||
}
|
||||
|
||||
async function fetchDiscoveredEndpoints(
|
||||
issuer: string,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<DiscoveredEndpoints> {
|
||||
const normalizedIssuer = normalizeIssuer(issuer);
|
||||
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(discoveryUrl);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new ConfigError(
|
||||
`OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
|
||||
"Check VITE_AUTHENTIK_ISSUER and network reachability.",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
|
||||
"Check VITE_AUTHENTIK_ISSUER.",
|
||||
);
|
||||
}
|
||||
|
||||
let doc: DiscoveryDocument;
|
||||
try {
|
||||
doc = (await response.json()) as DiscoveryDocument;
|
||||
} catch {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
|
||||
);
|
||||
}
|
||||
|
||||
const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
|
||||
const tokenEndpoint = nonEmptyString(doc.token_endpoint);
|
||||
const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
|
||||
const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
|
||||
const jwksUri = nonEmptyString(doc.jwks_uri);
|
||||
const discoveredIssuer = nonEmptyString(doc.issuer);
|
||||
|
||||
const endpointFields: Array<[string, string | undefined]> = [
|
||||
["authorization_endpoint", authorizationEndpoint],
|
||||
["token_endpoint", tokenEndpoint],
|
||||
["userinfo_endpoint", userinfoEndpoint],
|
||||
["end_session_endpoint", endSessionEndpoint],
|
||||
["jwks_uri", jwksUri],
|
||||
];
|
||||
const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
|
||||
if (missing.length > 0) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
|
||||
"The issuer must point at an OIDC provider that publishes these endpoints.",
|
||||
);
|
||||
}
|
||||
|
||||
if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
|
||||
throw new ConfigError(
|
||||
`OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
|
||||
`but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issuer: normalizedIssuer,
|
||||
authorizeEndpoint: authorizationEndpoint!,
|
||||
tokenEndpoint: tokenEndpoint!,
|
||||
userinfoEndpoint: userinfoEndpoint!,
|
||||
endSessionEndpoint: endSessionEndpoint!,
|
||||
jwksUri: jwksUri!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Session cache: one discovery fetch per issuer per SPA session. Failed
|
||||
* lookups are evicted so a transient network error can be retried instead of
|
||||
* poisoning the whole session.
|
||||
*/
|
||||
const discoveryCache = new Map<string, Promise<DiscoveredEndpoints>>();
|
||||
|
||||
export function resolveEndpointsFromDiscovery(
|
||||
issuer: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscoveredEndpoints> {
|
||||
const normalizedIssuer = normalizeIssuer(issuer);
|
||||
const cached = discoveryCache.get(normalizedIssuer);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
|
||||
discoveryCache.set(normalizedIssuer, pending);
|
||||
pending.catch(() => {
|
||||
discoveryCache.delete(normalizedIssuer);
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `app` with all OIDC endpoints filled in. When the config already
|
||||
* carries them (pre-resolved config, tests) this is a no-op; otherwise the
|
||||
* issuer's discovery document is fetched (cached for the session) and its
|
||||
* authorization/token/userinfo/end-session/jwks endpoints are used.
|
||||
*/
|
||||
export async function resolveEndpoints(
|
||||
app: AppConfig,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ResolvedAppConfig> {
|
||||
const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
|
||||
if (missing.length === 0) {
|
||||
return app as ResolvedAppConfig;
|
||||
}
|
||||
|
||||
const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
|
||||
return {
|
||||
...app,
|
||||
authorizeEndpoint: discovered.authorizeEndpoint,
|
||||
tokenEndpoint: discovered.tokenEndpoint,
|
||||
userinfoEndpoint: discovered.userinfoEndpoint,
|
||||
endSessionEndpoint: discovered.endSessionEndpoint,
|
||||
jwksUri: discovered.jwksUri,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* NestQuest Admin — global tokens + base.
|
||||
Plain CubeCraft styling, mobile-first, 390x844 reference
|
||||
(design/ADMIN-SPEC.md). Values mirror design/tokens/nestquest-admin-tokens.css,
|
||||
re-scoped to :root for the standalone PWA. */
|
||||
|
||||
:root {
|
||||
--nq-brand-blue: #3b3ab8;
|
||||
--nq-brand-purple: #a035cc;
|
||||
|
||||
--nq-a-page: #f4f4f6;
|
||||
--nq-a-surface: #ffffff;
|
||||
--nq-a-border: #e4e4ea;
|
||||
--nq-a-divider: #ededf1;
|
||||
|
||||
--nq-a-ink: #18181d;
|
||||
--nq-a-ink-secondary: #52525e;
|
||||
--nq-a-ink-tertiary: #70707e;
|
||||
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-tabbar-height: 70px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--nq-a-font);
|
||||
background: var(--nq-a-page);
|
||||
color: var(--nq-a-ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
import "./index.css";
|
||||
import { registerServiceWorker } from "./pwa/register";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppRoutes />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
void registerServiceWorker();
|
||||
@@ -0,0 +1,396 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
REVISION_MARKER,
|
||||
STATIC_PRECACHE_FILES,
|
||||
injectServiceWorker,
|
||||
} from "../../vite-sw-plugin";
|
||||
import { registerServiceWorker } from "./register";
|
||||
|
||||
const adminRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const adminPath = (rel: string) => resolve(adminRoot, rel);
|
||||
const publicPath = (urlPath: string) => adminPath(`public${urlPath}`);
|
||||
|
||||
interface ManifestIcon {
|
||||
src: string;
|
||||
sizes: string;
|
||||
type: string;
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(publicPath("/manifest.webmanifest"), "utf8"));
|
||||
const icons: ManifestIcon[] = manifest.icons;
|
||||
const swSource = readFileSync(publicPath("/sw.js"), "utf8");
|
||||
|
||||
const sizeOf = (icon: ManifestIcon) => Number(icon.sizes.split("x")[0]);
|
||||
const purposes = (icon: ManifestIcon) => (icon.purpose ?? "any").split(/\s+/);
|
||||
|
||||
describe("web app manifest", () => {
|
||||
it("meets the installability criteria", () => {
|
||||
expect(manifest.name).toBe("NestQuest Admin");
|
||||
expect(manifest.short_name).toBe("NestQuest");
|
||||
expect(manifest.start_url).toBe("/");
|
||||
expect(manifest.scope).toBe("/");
|
||||
expect(manifest.display).toBe("standalone");
|
||||
expect(manifest.background_color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(manifest.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
|
||||
const any = icons.filter((i) => purposes(i).includes("any"));
|
||||
expect(any.some((i) => sizeOf(i) >= 192)).toBe(true);
|
||||
expect(any.some((i) => sizeOf(i) >= 512)).toBe(true);
|
||||
expect(icons.some((i) => purposes(i).includes("maskable"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the admin design token colours", () => {
|
||||
const css = readFileSync(adminPath("src/index.css"), "utf8");
|
||||
expect(css).toContain(`--nq-brand-blue: ${manifest.theme_color};`);
|
||||
expect(css).toContain(`--nq-a-page: ${manifest.background_color};`);
|
||||
});
|
||||
|
||||
it.each(icons.map((i) => [i.src, i] as const))(
|
||||
"icon %s is a real PNG of its declared size",
|
||||
(_src, icon) => {
|
||||
const path = publicPath(icon.src);
|
||||
expect(existsSync(path)).toBe(true);
|
||||
const png = readFileSync(path);
|
||||
expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
expect(png.toString("ascii", 12, 16)).toBe("IHDR");
|
||||
const [w, h] = icon.sizes.split("x").map(Number);
|
||||
expect(png.readUInt32BE(16)).toBe(w);
|
||||
expect(png.readUInt32BE(20)).toBe(h);
|
||||
expect(icon.type).toBe("image/png");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("index.html and entry point", () => {
|
||||
const html = readFileSync(adminPath("index.html"), "utf8");
|
||||
|
||||
it("links the manifest, theme colour and apple touch icon", () => {
|
||||
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
|
||||
expect(html).toContain(`<meta name="theme-color" content="${manifest.theme_color}" />`);
|
||||
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/icon-192.png" />');
|
||||
});
|
||||
|
||||
it("main.tsx registers the service worker", () => {
|
||||
const main = readFileSync(adminPath("src/main.tsx"), "utf8");
|
||||
expect(main).toMatch(/import \{ registerServiceWorker \} from "\.\/pwa\/register"/);
|
||||
expect(main).toMatch(/registerServiceWorker\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal service-worker global scope: runs public/sw.js against in-memory
|
||||
// caches and a stubbed network, so handler behaviour is checked without I/O.
|
||||
function loadServiceWorker(
|
||||
network: (url: string) => Promise<FakeResponse>,
|
||||
source: string = swSource,
|
||||
) {
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
const stores = new Map<string, Map<string, FakeResponse>>();
|
||||
const openStore = (name: string) => {
|
||||
if (!stores.has(name)) stores.set(name, new Map());
|
||||
return stores.get(name)!;
|
||||
};
|
||||
const keyOf = (req: string | { url: string }) =>
|
||||
new URL(typeof req === "string" ? req : req.url, "https://admin.test").href;
|
||||
const fetchFn = vi.fn((req: string | { url: string }) => network(keyOf(req)));
|
||||
|
||||
const caches = {
|
||||
open: async (name: string) => {
|
||||
const store = openStore(name);
|
||||
return {
|
||||
addAll: async (urls: string[]) => {
|
||||
for (const u of urls) store.set(keyOf(u), await network(keyOf(u)));
|
||||
},
|
||||
put: async (req: { url: string }, res: FakeResponse) => void store.set(keyOf(req), res),
|
||||
};
|
||||
},
|
||||
keys: async () => [...stores.keys()],
|
||||
delete: async (name: string) => stores.delete(name),
|
||||
match: async (req: string | { url: string }, opts?: { cacheName?: string }) => {
|
||||
const names = opts?.cacheName ? [opts.cacheName] : [...stores.keys()];
|
||||
for (const n of names) {
|
||||
const hit = stores.get(n)?.get(keyOf(req));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const self = {
|
||||
location: new URL("https://admin.test/"),
|
||||
addEventListener: (type: string, fn: (event: any) => void) => (listeners[type] = fn),
|
||||
skipWaiting: vi.fn(async () => undefined),
|
||||
clients: { claim: vi.fn(async () => undefined) },
|
||||
};
|
||||
const ResponseStub = { error: () => new FakeResponse("", false, "error") };
|
||||
new Function("self", "caches", "fetch", "Response", source)(self, caches, fetchFn, ResponseStub);
|
||||
|
||||
const dispatch = async (type: string, init: object = {}) => {
|
||||
let pending: Promise<unknown> | undefined;
|
||||
const event = {
|
||||
...init,
|
||||
waitUntil: (p: Promise<unknown>) => (pending = p),
|
||||
respondWith: (p: Promise<unknown>) => (pending = p),
|
||||
};
|
||||
listeners[type](event);
|
||||
return { responded: pending !== undefined, result: await pending };
|
||||
};
|
||||
return { listeners, stores, self, fetchFn, dispatch, openStore };
|
||||
}
|
||||
|
||||
class FakeResponse {
|
||||
constructor(
|
||||
readonly body: string,
|
||||
readonly ok = true,
|
||||
readonly type = "basic",
|
||||
) {}
|
||||
clone() {
|
||||
return new FakeResponse(this.body, this.ok, this.type);
|
||||
}
|
||||
}
|
||||
|
||||
const request = (url: string, extra: object = {}) => ({
|
||||
url: new URL(url, "https://admin.test").href,
|
||||
method: "GET",
|
||||
mode: "cors",
|
||||
destination: "",
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("service worker (public/sw.js)", () => {
|
||||
it("is a static asset served from the site root", () => {
|
||||
expect(existsSync(publicPath("/sw.js"))).toBe(true);
|
||||
expect(swSource).toMatch(/const SHELL_CACHE = /);
|
||||
expect(swSource).toMatch(/addEventListener\("install"/);
|
||||
expect(swSource).toMatch(/addEventListener\("activate"/);
|
||||
expect(swSource).toMatch(/addEventListener\("fetch"/);
|
||||
});
|
||||
|
||||
it("precaches the shell on install, prunes stale caches on activate", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
sw.openStore("nestquest-admin-shell-v0");
|
||||
|
||||
await sw.dispatch("install");
|
||||
expect(sw.self.skipWaiting).toHaveBeenCalled();
|
||||
const precached = [...sw.stores.values()].flatMap((s) => [...s.keys()]);
|
||||
for (const path of ["/", "/index.html", "/manifest.webmanifest", ...icons.map((i) => i.src)]) {
|
||||
expect(precached).toContain(`https://admin.test${path}`);
|
||||
}
|
||||
|
||||
await sw.dispatch("activate");
|
||||
expect(sw.stores.has("nestquest-admin-shell-v0")).toBe(false);
|
||||
expect(sw.self.clients.claim).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves the cached shell for navigations when offline", async () => {
|
||||
let online = true;
|
||||
const sw = loadServiceWorker(async (url) => {
|
||||
if (!online) throw new TypeError("Failed to fetch");
|
||||
return new FakeResponse(`net:${url}`);
|
||||
});
|
||||
await sw.dispatch("install");
|
||||
online = false;
|
||||
|
||||
const { result } = await sw.dispatch("fetch", {
|
||||
request: request("/children/42", { mode: "navigate", destination: "document" }),
|
||||
});
|
||||
expect((result as FakeResponse).body).toBe("net:https://admin.test/index.html");
|
||||
});
|
||||
|
||||
it("serves static assets cache-first and fills the runtime cache", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
const asset = request("/assets/index-abc123.js", { destination: "script" });
|
||||
|
||||
const first = await sw.dispatch("fetch", { request: asset });
|
||||
expect((first.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
|
||||
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await sw.dispatch("fetch", { request: asset });
|
||||
expect((second.result as FakeResponse).body).toBe("net:https://admin.test/assets/index-abc123.js");
|
||||
expect(sw.fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never handles cross-origin, non-GET or non-static requests", async () => {
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`));
|
||||
const cases = [
|
||||
request("https://auth.example.com/logo.png", { destination: "image" }),
|
||||
request("/assets/app.js", { destination: "script", method: "POST" }),
|
||||
request("/api/admin/children"),
|
||||
];
|
||||
for (const req of cases) {
|
||||
const { responded } = await sw.dispatch("fetch", { request: req });
|
||||
expect(responded).toBe(false);
|
||||
}
|
||||
expect(sw.fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// A Vite-style built index.html referencing hashed bundles.
|
||||
const builtIndexHtml = (hash: string) => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<script type="module" crossorigin src="/assets/index-${hash}.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-${hash}.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-${hash}.css">
|
||||
<link rel="preconnect" href="https://auth.example.com" />
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>`;
|
||||
|
||||
// Same-origin script/link URLs of an HTML document, parsed independently of
|
||||
// the build plugin so the regression check does not trust its own extractor.
|
||||
const referencedAssets = (html: string) =>
|
||||
[...new DOMParser().parseFromString(html, "text/html").querySelectorAll("script[src], link[href]")]
|
||||
.map((el) => new URL(el.getAttribute("src") ?? el.getAttribute("href")!, "https://admin.test/"))
|
||||
.filter((url) => url.origin === "https://admin.test")
|
||||
.map((url) => url.href);
|
||||
|
||||
// The unhashed files the build hashes into the revision, read from public/.
|
||||
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||
path,
|
||||
bytes: readFileSync(publicPath(`/${path}`)),
|
||||
}));
|
||||
const revisionOf = (src: string) => /const REVISION = "([0-9a-f]+)";/.exec(src)?.[1];
|
||||
|
||||
describe("build-time service worker injection", () => {
|
||||
it("precaches every asset the cached index.html references on install (PWA-001)", async () => {
|
||||
const html = builtIndexHtml("Ab12Cd34");
|
||||
const built = injectServiceWorker(html, swSource, staticFiles);
|
||||
const sw = loadServiceWorker(async (url) =>
|
||||
new FakeResponse(url.endsWith("/index.html") ? html : `net:${url}`),
|
||||
built);
|
||||
|
||||
await sw.dispatch("install");
|
||||
const shell = [...sw.stores.entries()].find(([name]) => name.startsWith("nestquest-admin-shell-"));
|
||||
expect(shell).toBeDefined();
|
||||
const [, shellStore] = shell!;
|
||||
|
||||
const cachedHtml = shellStore.get("https://admin.test/index.html")!.body;
|
||||
const assets = referencedAssets(cachedHtml);
|
||||
expect(assets).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://admin.test/assets/index-Ab12Cd34.js",
|
||||
"https://admin.test/assets/vendor-Ab12Cd34.js",
|
||||
"https://admin.test/assets/index-Ab12Cd34.css",
|
||||
]),
|
||||
);
|
||||
for (const asset of assets) expect([...shellStore.keys()]).toContain(asset);
|
||||
expect([...shellStore.keys()]).not.toContain("https://auth.example.com/");
|
||||
});
|
||||
|
||||
it("derives a new revision for different index.html content (PWA-002)", () => {
|
||||
const a = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const b = injectServiceWorker(builtIndexHtml("bbbb2222"), swSource, staticFiles);
|
||||
|
||||
expect(a).not.toContain(REVISION_MARKER);
|
||||
expect(b).not.toContain(REVISION_MARKER);
|
||||
expect(revisionOf(a)).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(revisionOf(b)).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(revisionOf(a)).not.toBe(revisionOf(b));
|
||||
expect(injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles)).toBe(a);
|
||||
});
|
||||
|
||||
it("derives a new revision when only a precached static file changes (PWA-002)", () => {
|
||||
const html = builtIndexHtml("aaaa1111");
|
||||
const base = revisionOf(injectServiceWorker(html, swSource, staticFiles));
|
||||
const withChanged = (path: string) =>
|
||||
staticFiles.map((f) =>
|
||||
f.path === path ? { path, bytes: Buffer.concat([f.bytes, Buffer.from([0])]) } : f,
|
||||
);
|
||||
|
||||
expect(revisionOf(injectServiceWorker(html, swSource, [...staticFiles].reverse()))).toBe(base);
|
||||
for (const path of STATIC_PRECACHE_FILES) {
|
||||
const changed = revisionOf(injectServiceWorker(html, swSource, withChanged(path)));
|
||||
expect(changed, path).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(changed, path).not.toBe(base);
|
||||
}
|
||||
});
|
||||
|
||||
it("hashes every unhashed URL sw.js precaches (PWA-002)", () => {
|
||||
const shellUrls = [...swSource.matchAll(/^\s*"(\/[^"]*)",$/gm)].map((m) => m[1]);
|
||||
const fixed = shellUrls.filter((url) => url !== "/" && url !== "/index.html");
|
||||
expect(fixed.map((url) => url.slice(1)).sort()).toEqual([...STATIC_PRECACHE_FILES].sort());
|
||||
});
|
||||
|
||||
it("names both caches after the build revision (PWA-002)", async () => {
|
||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const revision = /const REVISION = "([0-9a-f]+)";/.exec(built)![1];
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||
|
||||
await sw.dispatch("install");
|
||||
await sw.dispatch("fetch", { request: request("/assets/late.js", { destination: "script" }) });
|
||||
expect([...sw.stores.keys()].sort()).toEqual([
|
||||
`nestquest-admin-runtime-${revision}`,
|
||||
`nestquest-admin-shell-${revision}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails fast when sw.js lacks the placeholders", () => {
|
||||
expect(() => injectServiceWorker(builtIndexHtml("x"), "const SHELL_URLS = [];", staticFiles)).toThrow();
|
||||
});
|
||||
|
||||
it("activation deletes only stale nestquest-admin caches (PWA-003)", async () => {
|
||||
const built = injectServiceWorker(builtIndexHtml("aaaa1111"), swSource, staticFiles);
|
||||
const sw = loadServiceWorker(async (url) => new FakeResponse(`net:${url}`), built);
|
||||
for (const name of [
|
||||
"nestquest-admin-shell-old",
|
||||
"nestquest-admin-runtime-old",
|
||||
"some-other-app-cache",
|
||||
]) {
|
||||
sw.openStore(name);
|
||||
}
|
||||
|
||||
await sw.dispatch("install");
|
||||
await sw.dispatch("activate");
|
||||
const names = [...sw.stores.keys()];
|
||||
expect(names).not.toContain("nestquest-admin-shell-old");
|
||||
expect(names).not.toContain("nestquest-admin-runtime-old");
|
||||
expect(names).toContain("some-other-app-cache");
|
||||
expect(names.some((n) => n.startsWith("nestquest-admin-shell-") && n !== "nestquest-admin-shell-old")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerServiceWorker", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const stubServiceWorker = (register: ReturnType<typeof vi.fn>) =>
|
||||
vi.stubGlobal("navigator", { ...navigator, serviceWorker: { register } });
|
||||
|
||||
it("does not register outside production builds", async () => {
|
||||
const register = vi.fn();
|
||||
stubServiceWorker(register);
|
||||
expect(import.meta.env.PROD).toBe(false);
|
||||
await registerServiceWorker();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers /sw.js in production", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
const register = vi.fn().mockResolvedValue({});
|
||||
stubServiceWorker(register);
|
||||
await registerServiceWorker();
|
||||
expect(register).toHaveBeenCalledWith("/sw.js", { scope: "/" });
|
||||
});
|
||||
|
||||
it("logs instead of throwing when registration fails", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
stubServiceWorker(vi.fn().mockRejectedValue(new Error("boom")));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await expect(registerServiceWorker()).resolves.toBeUndefined();
|
||||
expect(error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the browser lacks service workers", async () => {
|
||||
vi.stubEnv("PROD", true);
|
||||
vi.stubGlobal("navigator", {});
|
||||
await expect(registerServiceWorker()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Service worker registration for the installable admin PWA.
|
||||
* Registers /sw.js only in production builds, so dev servers and tests never
|
||||
* run a worker. Failures are logged, never thrown — the app works without it.
|
||||
*/
|
||||
export async function registerServiceWorker(): Promise<void> {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
} catch (error) {
|
||||
console.error("Service worker registration failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Build-time service worker injection. After Vite writes the bundle, the
|
||||
hashed asset URLs referenced by dist/index.html and a content-derived
|
||||
revision are written into dist/sw.js, so the offline shell is complete right
|
||||
after install and every release ships a new worker with fresh caches.
|
||||
Build tooling only — never imported by the app bundle. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
export const REVISION_MARKER = "__NQ_BUILD_REVISION__";
|
||||
const PRECACHE_PLACEHOLDER = "const PRECACHE_ASSETS = [];";
|
||||
// Unhashed files sw.js precaches by fixed URL, relative to the build output.
|
||||
export const STATIC_PRECACHE_FILES = [
|
||||
"manifest.webmanifest",
|
||||
"icons/icon-192.png",
|
||||
"icons/icon-512.png",
|
||||
"icons/icon-maskable-512.png",
|
||||
];
|
||||
|
||||
export interface PrecacheFile {
|
||||
path: string;
|
||||
bytes: string | Uint8Array;
|
||||
}
|
||||
|
||||
// Every same-origin URL an index.html loads via <script src> or <link href>,
|
||||
// normalised to an absolute path, in document order and without duplicates.
|
||||
export function extractShellAssets(indexHtml: string): string[] {
|
||||
const tags = indexHtml.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
|
||||
const urls = tags.flatMap((tag) => {
|
||||
const attr = /\b(?:src|href)\s*=\s*["']([^"']+)["']/i.exec(tag);
|
||||
if (!attr) return [];
|
||||
const url = new URL(attr[1], "https://same-origin.invalid/");
|
||||
return url.origin === "https://same-origin.invalid" ? [url.pathname] : [];
|
||||
});
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
// Hash of every precached file, sorted by path; each entry is length-prefixed
|
||||
// so no two different file sets hash the same concatenation.
|
||||
export function computeRevision(files: PrecacheFile[]): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const { path, bytes } of [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))) {
|
||||
const data = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
|
||||
hash.update(`${path}\0${data.byteLength}\0`).update(data);
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
// Returns swSource with the precache list and the build revision filled in.
|
||||
// The revision covers index.html (and so the hashed bundles it names), the
|
||||
// sw.js source and the bytes of every unhashed static file it precaches.
|
||||
export function injectServiceWorker(
|
||||
indexHtml: string,
|
||||
swSource: string,
|
||||
staticFiles: PrecacheFile[],
|
||||
): string {
|
||||
if (!swSource.includes(PRECACHE_PLACEHOLDER) || !swSource.includes(REVISION_MARKER)) {
|
||||
throw new Error("sw.js is missing the PRECACHE_ASSETS placeholder or revision marker");
|
||||
}
|
||||
const assets = extractShellAssets(indexHtml);
|
||||
const revision = computeRevision([
|
||||
{ path: "index.html", bytes: indexHtml },
|
||||
{ path: "sw.js", bytes: swSource },
|
||||
...staticFiles,
|
||||
]);
|
||||
return swSource
|
||||
.replace(PRECACHE_PLACEHOLDER, `const PRECACHE_ASSETS = ${JSON.stringify(assets)};`)
|
||||
.replaceAll(REVISION_MARKER, revision);
|
||||
}
|
||||
|
||||
export function serviceWorkerInjectPlugin(): Plugin {
|
||||
let outDir = "";
|
||||
return {
|
||||
name: "nestquest-sw-inject",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
outDir = resolve(config.root, config.build.outDir);
|
||||
},
|
||||
writeBundle() {
|
||||
const swPath = resolve(outDir, "sw.js");
|
||||
const indexHtml = readFileSync(resolve(outDir, "index.html"), "utf8");
|
||||
const staticFiles = STATIC_PRECACHE_FILES.map((path) => ({
|
||||
path,
|
||||
bytes: readFileSync(resolve(outDir, path)),
|
||||
}));
|
||||
writeFileSync(
|
||||
swPath,
|
||||
injectServiceWorker(indexHtml, readFileSync(swPath, "utf8"), staticFiles),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { serviceWorkerInjectPlugin } from "./vite-sw-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), serviceWorkerInjectPlugin()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user