fix(cropper): make load failures visible; loud config errors

'Load next queued' did nothing because the running server had been started
without its env file (a restart used '-f .env' from tools/, where no .env
exists), so the cropper was unconfigured and every action answered 503 —
and none of that was visible, because notifications never rendered either:

- notifications used cds-toast/cds-toast-item, which the Carbon bundle does
  not define; a cds-toast-notification element renders nothing standalone in
  this build. The cropper now uses cds-inline-notification in the fixed
  toast viewport (renders, themed, close button) like the wizard
- an unconfigured deployment now says so on the page (503 'Cropper
  unavailable') instead of rendering actions that all fail, and logs a
  startup warning naming the missing credentials
- tests: notifications use a registered element; unconfigured page 503
This commit is contained in:
2026-10-01 04:36:31 +01:00
parent cb5b151988
commit 95200b6abf
6 changed files with 44 additions and 11 deletions
+10
View File
@@ -20,6 +20,16 @@ const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
* credential is missing — the router then answers 503 and nothing else in the
* app is affected. URLs are hardcoded here; only credentials come from env.
*/
/** Credentials the cropper needs (used for the startup warning). */
export const CROPPER_CREDENTIALS = [
'MINIO_ACCESS_KEY',
'MINIO_SECRET_KEY',
'AZURE_STORAGE_CONNECTION_STRING',
'NODERED_HTTP_USERNAME',
'NODERED_HTTP_PASSWORD',
'CARVEKIT_API_KEY',
];
export function buildCropperClients(env = process.env) {
const {
MINIO_ACCESS_KEY,
+3 -4
View File
@@ -37,10 +37,9 @@
<!-- notifications -->
<div class="toast-viewport" aria-live="polite">
<cds-toast>
<cds-toast-item v-for="t in toasts" v-bind:key="t.id" v-bind:kind="t.kind"
v-bind:title="t.title" v-bind:caption="t.caption" timeout="6000"></cds-toast-item>
</cds-toast>
<cds-inline-notification v-for="t in toasts" v-bind:key="t.id" v-bind:kind="t.kind"
v-bind:title="t.title" v-bind:subtitle="t.caption" low-contrast
v-on:cds-notification-closed="removeToast(t.id)"></cds-inline-notification>
</div>
<!-- 1 · load -->
+3 -2
View File
@@ -20,7 +20,7 @@ body.app {
/* prevent FOUC on web components */
cds-button:not(:defined), cds-tag:not(:defined), cds-tile:not(:defined),
cds-clickable-tile:not(:defined), cds-inline-notification:not(:defined),
cds-toast:not(:defined), cds-toast-item:not(:defined), cds-progress-bar:not(:defined) {
cds-progress-bar:not(:defined) {
visibility: hidden;
}
/* Carbon styles the shadow anchor inside tiles, not the host — give the host
@@ -183,7 +183,8 @@ cds-button::part(button) { font-size: 14px; }
.review-options .form-check { margin-bottom: 0.35rem; }
/* ── notifications viewport (cropper toasts) ────────────────────────────── */
.toast-viewport { position: fixed; top: 3.5rem; right: 1rem; z-index: 1100; width: min(360px, calc(100vw - 2rem)); }
.toast-viewport { position: fixed; top: 3.5rem; right: 1rem; z-index: 1100; width: min(380px, calc(100vw - 2rem)); display: flex; flex-direction: column; gap: 0.5rem; }
.toast-viewport cds-inline-notification:not(:defined) { visibility: hidden; }
/* ── footer ─────────────────────────────────────────────────────────────── */
footer.foot { flex-shrink: 0; padding: 1.1rem 0 1.3rem; color: var(--cds-text-helper, #6f6f6f); font-size: 12px; border-top: 1px solid var(--cds-border-subtle, #c6c6c6); }
+15 -1
View File
@@ -12,7 +12,7 @@ import fs from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
import { createCropperRouter, buildCropperClients } from './cropper.js';
import { createCropperRouter, buildCropperClients, CROPPER_CREDENTIALS } from './cropper.js';
// ── Hardcoded configuration ──────────────────────────────────────────────────
export const PORT = 4173;
@@ -471,6 +471,14 @@ export function createApp(overrides = {}) {
next();
};
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
if (!cropperClients) {
return errorPage(
res,
503,
'Cropper unavailable',
'The image cropper is not configured on this deployment. Contact your administrator.'
);
}
renderPage(
res,
'cropper/index.html',
@@ -479,6 +487,12 @@ export function createApp(overrides = {}) {
);
});
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
if (!cropperClients) {
const missing = CROPPER_CREDENTIALS.filter((name) => !process.env[name]);
console.warn(
`[cropper] not configured — missing ${missing.join(', ')}; /cropper will answer 503`
);
}
if (cropperClients) {
app.use(
'/cropper',
+9
View File
@@ -217,6 +217,10 @@ test('unconfigured deployment: cropper answers 503, other tools unaffected', asy
const cookies = await login(base);
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
assert.equal(res.status, 503);
// the page says so too, rather than rendering actions that all fail
const page = await fetch(`${base}/cropper`, { headers: { cookie: cookies } });
assert.equal(page.status, 503);
assert.match(await page.text(), /not configured/i);
const landing = await fetch(`${base}/`);
assert.equal(landing.status, 200);
} finally {
@@ -233,6 +237,11 @@ test('crop gate: permitted operator loads the page; denied operator does not', a
const html = await page.text();
assert.match(html, /Smart Photo Editor|Image Cropper/);
assert.match(html, /window\.__SESSION__ = \{/, 'session injected server-side');
assert.match(html, /cds-header-nav-item href="\/cropper" aria-current="page"/);
// notifications must use a component the bundle actually defines
assert.match(html, /cds-inline-notification/);
assert.ok(!/cds-toast-item/.test(html), 'cds-toast-item is not a registered element');
assert.match(html, /cds-side-nav-link href="\/cropper" aria-current="page"/);
} finally {
await ok.stop();
}
+4 -4
View File
@@ -316,11 +316,11 @@ test('the active tool is marked on both navigation surfaces', async () => {
const { base, stop } = await bootApp();
try {
const cookies = await login(base);
const cropper = await (await fetch(`${base}/cropper`, { headers: { cookie: cookies } })).text();
assert.match(cropper, /cds-header-nav-item href="\/cropper" aria-current="page"/);
assert.match(cropper, /cds-side-nav-link href="\/cropper" aria-current="page"/);
const wizard = await (await fetch(`${base}/fingerprint`, { headers: { cookie: cookies } })).text();
assert.match(wizard, /cds-header-nav-item href="\/fingerprint" aria-current="page"/);
assert.match(wizard, /cds-side-nav-link href="\/fingerprint" aria-current="page"/);
assert.ok(
!/cds-header-nav-item href="\/fingerprint" aria-current/.test(cropper),
!/cds-header-nav-item href="\/cropper" aria-current/.test(wizard),
'the other tool is not marked current'
);
} finally {