fix(cropper): professional permission-denied page

Centered card with navbar, lock icon, sans-serif stack (error pages
previously rendered in the browser default serif), and action-aware
copy: permission denial offers only 'Back to Tools' instead of a
pointless 'Try again'; auth failures keep Try again + Back to Tools.
This commit is contained in:
2026-10-01 01:35:37 +01:00
parent 2f0a1fab21
commit 22d9ba40e3
3 changed files with 31 additions and 9 deletions
+5 -2
View File
@@ -21,6 +21,7 @@
html, body { height: 100%; }
body.app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background:
radial-gradient(1200px 400px at 80% -10%, rgba(59, 79, 216, 0.08), transparent 60%),
var(--bg);
@@ -31,6 +32,7 @@ body.app {
flex-direction: column;
}
[v-cloak] { display: none; }
a { text-decoration: none; }
/* ── navbar ─────────────────────────────────────────────── */
.nav {
@@ -60,7 +62,7 @@ body.app {
/* ── layout ─────────────────────────────────────────────── */
main.container { flex: 1 0 auto; }
.narrow { max-width: 560px; }
.narrow { max-width: 560px; margin-left: auto; margin-right: auto; }
.center-v { display: flex; flex-direction: column; justify-content: center; min-height: 70vh; }
.page-head { margin: 2.2rem 0 1.4rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.page-head h1 { font-size: 1.45rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
@@ -113,7 +115,8 @@ label { font-weight: 600; font-size: 13px; color: var(--ink); margin-bottom: 0.3
select.form-control { appearance: auto; }
/* ── buttons ────────────────────────────────────────────── */
.btn { border-radius: 10px; font-weight: 600; font-size: 14px; padding: 0.55rem 1.1rem; border: 1px solid transparent; }
.btn { border-radius: 10px; font-weight: 600; font-size: 14px; padding: 0.55rem 1.1rem; border: 1px solid transparent; text-decoration: none; }
a.btn:hover, a.btn:focus { text-decoration: none; }
.btn:focus { box-shadow: 0 0 0 3px rgba(59, 79, 216, 0.18); }
.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; }
.btn-primary:hover, .btn-primary:focus { background: var(--brand-dark); border-color: var(--brand-dark); color: #fff; }
+25 -6
View File
@@ -58,7 +58,10 @@ function readProfile(raw) {
}
}
function errorPage(res, status, title, message) {
function errorPage(res, status, title, message, actions = {}) {
const primary = actions.primary || { href: '/auth/login', label: 'Try again' };
const secondary = actions.secondary === null ? null : actions.secondary || { href: '/', label: 'Back to Tools' };
const icon = actions.icon || 'alert';
res
.status(status)
.type('html')
@@ -71,13 +74,26 @@ function errorPage(res, status, title, message) {
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="app">
<nav class="nav">
<div class="container">
<a class="brand" href="/">
<span class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a8 8 0 0 0-8 8c0 3.5 1 5.5 2 7"/><path d="M16 21a10 10 0 0 0 4-13 8 8 0 0 0-15.7-1"/><path d="M8 21a8 8 0 0 1-1-11 5 5 0 0 1 9.7 1.4A6 6 0 0 1 12 17"/><path d="M12 21a4 4 0 0 0 4-4 4 4 0 0 0-1.2-2.9"/></svg>
</span>
YOUreka <small>Tools</small>
</a>
<div class="nav-right">
<a class="linklike" href="/auth/logout">Sign out</a>
</div>
</div>
</nav>
<main class="container narrow center-v">
<div class="card message-card">
<div class="message-icon error">${ICONS.alert}</div>
<div class="message-icon error">${ICONS[icon] || ICONS.alert}</div>
<h1>${title}</h1>
<p class="muted">${message}</p>
<a class="btn btn-primary" href="/auth/login">Try again</a>
<a class="btn btn-ghost" href="/">Back to Tools</a>
<a class="btn btn-primary" href="${primary.href}">${primary.label}</a>
${secondary ? `<a class="btn btn-ghost" href="${secondary.href}">${secondary.label}</a>` : ''}
</div>
</main>
</body>
@@ -87,6 +103,8 @@ function errorPage(res, status, title, message) {
const ICONS = {
alert:
'<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
lock:
'<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
};
// ── app factory ──────────────────────────────────────────────────────────────
@@ -304,8 +322,9 @@ export function createApp(overrides = {}) {
return errorPage(
res,
403,
'Not permitted',
'You are not permitted to crop images. Contact your administrator if you believe this is a mistake.'
'No access',
'Your account doesn\u2019t have permission to crop images. Ask a domain administrator to grant you access.',
{ icon: 'lock', primary: { href: '/', label: 'Back to Tools' }, secondary: null }
);
}
next();
+1 -1
View File
@@ -237,7 +237,7 @@ test('crop gate: permitted operator loads the page; denied operator does not', a
const cookies = await login(denied.base);
const page = await fetch(`${denied.base}/cropper`, { headers: { cookie: cookies } });
assert.equal(page.status, 403);
assert.match(await page.text(), /not permitted/i);
assert.match(await page.text(), /permission to crop images/i);
} finally {
await denied.stop();
}