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