diff --git a/package-lock.json b/package-lock.json
index ff0fe95..59a75b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,8 @@
"devDependencies": {
"alpinejs": "^3.14.1",
"beercss": "^3.7.0",
- "material-dynamic-colors": "^1.1.2"
+ "material-dynamic-colors": "^1.1.2",
+ "playwright": "^1.61.0"
}
},
"node_modules/@material/material-color-utilities": {
@@ -577,6 +578,21 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -949,6 +965,38 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "node_modules/playwright": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
+ "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
+ "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
diff --git a/package.json b/package.json
index 8a26dcc..54a0a54 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"devDependencies": {
"alpinejs": "^3.14.1",
"beercss": "^3.7.0",
- "material-dynamic-colors": "^1.1.2"
+ "material-dynamic-colors": "^1.1.2",
+ "playwright": "^1.61.0"
}
}
diff --git a/public/admin.html b/public/admin.html
index 1de359e..13f0a9c 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -7,7 +7,11 @@
-
+
+
diff --git a/public/index.html b/public/index.html
index 54d44d8..69a2a53 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,7 +7,11 @@
-
+
+
diff --git a/public/js/api.js b/public/js/api.js
index 6239276..048abde 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -23,6 +23,14 @@ window.API = (function () {
try { data = JSON.parse(text); } catch (_) { data = { raw: text }; }
}
if (!res.ok) {
+ // An expired/invalid session (401) on any call other than the login POST
+ // means we should send the user back to sign in, rather than leaving the
+ // page in a broken half-loaded state with only a console error.
+ const onLoginPage = window.location.pathname.endsWith('/login.html');
+ const isLoginCall = url.endsWith('/api/login');
+ if (res.status === 401 && !onLoginPage && !isLoginCall) {
+ window.location.href = '/login.html';
+ }
const err = new Error((data && data.error) || `Request failed (${res.status})`);
err.status = res.status;
throw err;
diff --git a/public/login.html b/public/login.html
index 6260c2b..ab8cd4d 100644
--- a/public/login.html
+++ b/public/login.html
@@ -7,7 +7,11 @@
-
+
+
diff --git a/scripts/vendor.js b/scripts/vendor.js
index 84fb56d..ed4a139 100644
--- a/scripts/vendor.js
+++ b/scripts/vendor.js
@@ -11,6 +11,12 @@ const OUT = path.join(ROOT, 'public', 'vendor');
const FILES = [
['beercss/dist/cdn/beer.min.css', 'beer.min.css'],
['beercss/dist/cdn/beer.min.js', 'beer.min.js'],
+ // Material Symbols icon fonts. beer.min.css references these by relative URL
+ // (with a CDN fallback our CSP blocks), so they must sit beside it in /vendor.
+ ['beercss/dist/cdn/material-symbols-outlined.woff2', 'material-symbols-outlined.woff2'],
+ ['beercss/dist/cdn/material-symbols-rounded.woff2', 'material-symbols-rounded.woff2'],
+ ['beercss/dist/cdn/material-symbols-sharp.woff2', 'material-symbols-sharp.woff2'],
+ ['beercss/dist/cdn/material-symbols-subset.woff2', 'material-symbols-subset.woff2'],
['material-dynamic-colors/dist/cdn/material-dynamic-colors.min.js', 'material-dynamic-colors.min.js'],
['alpinejs/dist/cdn.min.js', 'alpine.min.js'],
];
@@ -29,5 +35,35 @@ for (const [src, dest] of FILES) {
console.error(`FAILED to vendor ${src}: ${e.message}`);
}
}
+// Strip Beer CSS's remote @font-face fallbacks (jsdelivr CDN) so the icon font
+// resolves only to our local /vendor copy — no CDN load, no CSP violation.
+const beerCss = path.join(OUT, 'beer.min.css');
+try {
+ const before = fs.readFileSync(beerCss, 'utf8');
+ const after = before.replace(/,url\(https:\/\/cdn\.jsdelivr\.net[^)]*\)\s*format\("woff2"\)/g, '');
+ if (after !== before) {
+ fs.writeFileSync(beerCss, after);
+ console.log('stripped remote CDN font fallbacks from beer.min.css');
+ }
+} catch (e) {
+ console.error(`WARN: could not post-process beer.min.css: ${e.message}`);
+}
+
+// material-dynamic-colors ships as an ES module: it sets the
+// globalThis.materialDynamicColors global (which Beer CSS's ui("theme") needs)
+// but ALSO ends with `export default ...`, which breaks classic