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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user