feat(ui): auth-gate the landing, add Help page and Home nav

- Landing shows the tool tiles only when signed in and a sign-in card
  otherwise; the login callback now returns to /.
- New public /help page: intro plus a per-tool Carbon accordion.
- Nav restructured to Home, {services}, divider, Help, Sign In/Out in
  both the header and the side nav; the header right keeps only the
  identity chip. Side-nav logo enlarged, border removed, spacing
  equalised. Sign-out modal no longer flashes before it is defined.
- Per-deploy asset versioning (?v=) with immutable asset caching and
  no-store HTML; help step lists show their numbers again.
- Tests updated and extended (48 total).
This commit is contained in:
2026-10-01 14:39:50 +01:00
parent 134edb6ebd
commit a218835a1c
5 changed files with 289 additions and 79 deletions
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Help · YOUreka Tools</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@carbon/styles@1.69.0/css/styles.min.css">
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/accordion.min.js"></script>
</head>
<body class="app cds--g100">
<!--app-header-->
<main class="container">
<div class="page-head">
<div>
<h1>Help</h1>
<p class="sub">How to use the YOUreka Tools</p>
</div>
</div>
<p class="help-intro">
YOUreka Tools is a set of field utilities for YOUreka ID domains. Sign in
with your YOUreka account to use the Image Cropper and the Fingerprint
Agent. Everything runs on this machine &mdash; nothing is uploaded except
the artifacts you explicitly save. Permissions are per domain, so an
administrator must grant you access to the domains you work on.
</p>
<cds-accordion>
<cds-accordion-item title="Image Cropper">
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
<ol class="help-steps">
<li>Open <strong>Image Cropper</strong> from the landing page or the menu.</li>
<li>Load a picture with <strong>Load next queued</strong>, or paste a queue item with <strong>Load by upload ID</strong>.</li>
<li>Check the details (phone, domain, agent) and crop the photo in the editor.</li>
<li>If the background needs cleaning, choose <strong>Requires cleaning? &rarr; Yes</strong>, then click <strong>Clean background</strong>.</li>
<li>To keep it, click <strong>Accept picture</strong>, then <strong>Crop and upload picture</strong>. To reject it, click <strong>Reject picture</strong>, tick the reasons (and fill in <em>Others</em> if asked), then <strong>Send review</strong>.</li>
</ol>
<p class="help-note">The cropper requires the <em>crop_image</em> permission. If you see &ldquo;No access&rdquo;, ask a domain administrator to grant it.</p>
</cds-accordion-item>
<cds-accordion-item title="Fingerprint Agent">
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
<ol class="help-steps">
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
<li>Choose the domain, enter the staff reference and the OTP, then look up the profile.</li>
<li>Confirm the profile is the right person.</li>
<li>Follow the on-screen prompts and place each finger on the attached scanner when asked.</li>
<li>Review the captured prints and save.</li>
</ol>
<p class="help-note">The scanner runs locally through the <em>fpagent</em> service; scans never leave this machine. Domain permissions are re-checked periodically and cached for a few minutes.</p>
</cds-accordion-item>
</cds-accordion>
<p class="help-note">
Your session lasts 30 days. Use <strong>Sign out</strong> in the menu to end it early.
</p>
</main>
<footer class="foot">
<div class="container">
<span>YOUreka Tools &middot; Help</span>
<span>Need more detail? Ask your YOUreka ID administrator.</span>
</div>
</footer>
</body>
</html>
+19 -7
View File
@@ -26,11 +26,13 @@ cds-progress-bar:not(:defined) {
/* Carbon styles the shadow anchor inside tiles, not the host — give the host /* Carbon styles the shadow anchor inside tiles, not the host — give the host
a real box so grid columns and click targets work correctly. */ a real box so grid columns and click targets work correctly. */
cds-tile, cds-clickable-tile { display: block; } cds-tile, cds-clickable-tile { display: block; }
/* an undefined modal renders its header/body/footer inline — hide it until defined */
cds-modal:not(:defined) { display: none; }
/* ── header + side nav (Carbon ui-shell) ────────────────────────────────── */ /* ── header + side nav (Carbon ui-shell) ────────────────────────────────── */
cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; } cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; }
.brand-inner { display: inline-flex; align-items: center; gap: 0.5rem; } .brand-inner { display: inline-flex; align-items: center; gap: 0.5rem; }
.brand-logo { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 4px; padding: 2px; } .brand-logo { display: inline-flex; align-items: center; justify-content: center; }
.brand-logo img { display: block; } .brand-logo img { display: block; }
.brand-suffix { color: var(--cds-text-secondary, #525252); font-weight: 400; } .brand-suffix { color: var(--cds-text-secondary, #525252); font-weight: 400; }
@@ -61,13 +63,13 @@ cds-side-nav[expanded] { pointer-events: auto; }
} }
.user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--cds-text-primary, #161616); font-size: 13px; } .user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--cds-text-primary, #161616); font-size: 13px; }
.user-chip .avatar { width: 26px; height: 26px; border-radius: 50%; background: #0f62fe; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; } .user-chip .avatar { width: 26px; height: 26px; border-radius: 50%; background: #0f62fe; color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }
.linklike { background: none; border: none; padding: 0; color: var(--cds-link-primary, #0f62fe); font-weight: 400; cursor: pointer; font-size: 13px; font-family: inherit; }
.linklike:hover { color: var(--cds-link-primary-hover, #0043ce); text-decoration: underline; }
.linklike[disabled] { color: var(--cds-text-disabled, #c6c6c6); cursor: default; }
/* logo above the mobile side-nav items */ /* logo above the mobile side-nav items */
.side-nav-brand { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; color: var(--cds-text-primary, #161616); font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--cds-border-subtle, #e0e0e0); } .side-nav-brand { display: flex; align-items: center; gap: 0.75rem; padding: 2rem 1rem 1rem; color: var(--cds-text-primary, #161616); font-weight: 600; font-size: 14px; }
.side-nav-brand img { border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 6px; padding: 2px; display: block; } .side-nav-brand img { display: block; }
/* separator between the service links and Help in the header nav */
.header-nav-divider { width: 1px; align-self: stretch; margin: 0.7rem 0.25rem; background: var(--cds-border-subtle, #e0e0e0); }
/* ── layout ─────────────────────────────────────────────────────────────── */ /* ── layout ─────────────────────────────────────────────────────────────── */
.container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; } .container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; }
@@ -114,6 +116,14 @@ main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
.tool-card .go { margin-top: auto; color: var(--cds-link, #0f62fe); font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 0.3rem; } .tool-card .go { margin-top: auto; color: var(--cds-link, #0f62fe); font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 0.3rem; }
.tool-card .go svg { width: 14px; height: 14px; } .tool-card .go svg { width: 14px; height: 14px; }
/* ── help page ──────────────────────────────────────────────────────────── */
cds-accordion { display: block; max-width: 720px; }
.help-intro { max-width: 720px; color: var(--cds-text-secondary, #525252); margin: 0 0 1.5rem; font-size: 14px; }
.help-steps { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; }
.help-steps li { margin-bottom: 0.35rem; }
.help-steps li::marker { color: var(--cds-text-secondary, #525252); }
.help-note { color: var(--cds-text-secondary, #525252); font-size: 13px; max-width: 720px; }
/* ── forms (Carbon-styled native controls) ──────────────────────────────── */ /* ── forms (Carbon-styled native controls) ──────────────────────────────── */
label { font-weight: 400; font-size: 12px; color: var(--cds-text-secondary, #525252); margin-bottom: 0.25rem; display: block; } label { font-weight: 400; font-size: 12px; color: var(--cds-text-secondary, #525252); margin-bottom: 0.25rem; display: block; }
.form-control { .form-control {
@@ -131,9 +141,10 @@ textarea.form-control { height: auto; padding: 0.6rem 0.75rem; }
.btn { .btn {
border-radius: 0; font-weight: 400; font-size: 14px; border-radius: 0; font-weight: 400; font-size: 14px;
padding: 0.65rem 0.95rem; border: 1px solid transparent; padding: 0.65rem 0.95rem; border: 1px solid transparent;
text-decoration: none; display: inline-block; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; cursor: pointer;
font-family: inherit; line-height: 1.2; text-align: center; font-family: inherit; line-height: 1.2; text-align: center;
} }
.btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.btn-primary { background: var(--cds-button-primary, #0f62fe); border-color: var(--cds-button-primary, #0f62fe); color: #fff; } .btn-primary { background: var(--cds-button-primary, #0f62fe); border-color: var(--cds-button-primary, #0f62fe); color: #fff; }
.btn-primary:hover, .btn-primary:focus { background: #0353e9; border-color: #0353e9; color: #fff; } .btn-primary:hover, .btn-primary:focus { background: #0353e9; border-color: #0353e9; color: #fff; }
.btn-ghost { background: transparent; border-color: var(--cds-border-strong, #8d8d8d); color: var(--cds-text-primary, #161616); } .btn-ghost { background: transparent; border-color: var(--cds-border-strong, #8d8d8d); color: var(--cds-text-primary, #161616); }
@@ -196,4 +207,5 @@ footer.foot a { color: var(--cds-text-secondary, #525252); }
.message-card h1 { font-size: 1.25rem; font-weight: 600; margin: 0; } .message-card h1 { font-size: 1.25rem; font-weight: 600; margin: 0; }
.message-icon { width: 52px; height: 52px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: var(--cds-support-error, #da1e28); color: #fff; margin-bottom: 0.4rem; } .message-icon { width: 52px; height: 52px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; background: var(--cds-support-error, #da1e28); color: #fff; margin-bottom: 0.4rem; }
.message-icon.error svg { width: 24px; height: 24px; } .message-icon.error svg { width: 24px; height: 24px; }
.message-icon.info { background: var(--cds-button-primary, #0f62fe); }
.message-card .btn { margin-top: 0.6rem; min-width: 200px; } .message-card .btn { margin-top: 0.6rem; min-width: 200px; }
+2 -28
View File
@@ -22,36 +22,10 @@
<main class="container"> <main class="container">
<div class="hero"> <div class="hero">
<h1>YOUreka Tools</h1> <h1>YOUreka Tools</h1>
<p>Field utilities for YOUreka ID Domains. Sign in with your YOUreka account to use the tools that require it.</p> <p>Field utilities for YOUreka ID Domains.</p>
</div> </div>
<div class="tool-grid"> <!--landing-view-->
<cds-clickable-tile href="/cropper">
<div class="tool-card">
<span class="tool-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
</span>
<h3>Image Cropper</h3>
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
<span class="go">Open Image Cropper
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</span>
</div>
</cds-clickable-tile>
<cds-clickable-tile href="/fingerprint">
<div class="tool-card">
<span class="tool-icon">
<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>
<h3>Fingerprint Agent</h3>
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
<span class="go">Open Fingerprint Agent
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</span>
</div>
</cds-clickable-tile>
</div>
</main> </main>
<footer class="foot"> <footer class="foot">
+112 -23
View File
@@ -31,6 +31,18 @@ export const DOMAINS = [
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Bumped once per process start, so every deployment serves fresh asset URLs.
export const DEPLOY_VERSION = Date.now().toString(36);
/**
* Append the per-deploy version to every /assets URL in a served page, so the
* browser fetches fresh CSS/JS/images after a deploy instead of reusing cached
* ones. Idempotent: URLs that already carry `?v=` are left alone.
*/
function versionAssets(html) {
return html.replace(/(["'(])(\/assets\/[^"')\s?]+)/g, `$1$2?v=${DEPLOY_VERSION}`);
}
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
// Express does not parse cookies; this is all we need. // Express does not parse cookies; this is all we need.
@@ -66,7 +78,7 @@ function errorPage(res, status, title, message, actions = {}) {
res res
.status(status) .status(status)
.type('html') .type('html')
.send(`<!doctype html> .send(versionAssets(`<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -90,7 +102,7 @@ function errorPage(res, status, title, message, actions = {}) {
</div> </div>
</main> </main>
</body> </body>
</html>`); </html>`));
} }
const ICONS = { const ICONS = {
@@ -122,10 +134,13 @@ const appHeader = (session, active) => {
const sideLink = (href, label, key, icon) => const sideLink = (href, label, key, icon) =>
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${icon || ''}${label}</cds-side-nav-link>`; `<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${icon || ''}${label}</cds-side-nav-link>`;
const name = session ? (session.firstName || session.phone || '') : ''; const name = session ? (session.firstName || session.phone || '') : '';
const right = session // Auth is a menu item now, so the header's right side only carries identity.
? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span> const headerAuth = session
<a class="linklike" href="/auth/logout" data-signout>Sign out</a>` ? '<cds-header-nav-item href="/auth/logout" data-signout>Sign out</cds-header-nav-item>'
: '<a class="linklike" href="/auth/login">Sign in</a>'; : '<cds-header-nav-item href="/auth/login">Sign in</cds-header-nav-item>';
const sideAuth = session
? `<cds-side-nav-link href="/auth/logout" data-signout>${SIDE_NAV_ICONS.logout}Sign out</cds-side-nav-link>`
: `<cds-side-nav-link href="/auth/login">${SIDE_NAV_ICONS.login}Sign in</cds-side-nav-link>`;
return ` return `
<cds-header aria-label="YOUreka Tools"> <cds-header aria-label="YOUreka Tools">
<cds-header-menu-button button-label-active="Close menu" button-label-inactive="Open menu" id="header-menu-button"></cds-header-menu-button> <cds-header-menu-button button-label-active="Close menu" button-label-inactive="Open menu" id="header-menu-button"></cds-header-menu-button>
@@ -133,25 +148,30 @@ const appHeader = (session, active) => {
<span class="brand-inner"><span class="brand-logo"><img src="/assets/youreka-logo.png" alt="" width="20" height="20"></span><span>YOUreka<span class="brand-suffix"> Tools</span></span></span> <span class="brand-inner"><span class="brand-logo"><img src="/assets/youreka-logo.png" alt="" width="20" height="20"></span><span>YOUreka<span class="brand-suffix"> Tools</span></span></span>
</cds-header-name> </cds-header-name>
<cds-header-nav> <cds-header-nav>
${toolLink('/cropper', 'Image Cropper', 'cropper')} ${toolLink('/', 'Home', 'home')}
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')} ${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
<span class="header-nav-divider" aria-hidden="true"></span>` : ''}
${toolLink('/help', 'Help', 'help')}
${headerAuth}
</cds-header-nav> </cds-header-nav>
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''} ${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
<div class="header-right">${right}</div> ${session ? `<div class="header-right"><span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span></div>` : ''}
</cds-header> </cds-header>
<cds-side-nav aria-label="Tools" id="side-nav"> <cds-side-nav aria-label="Tools" id="side-nav">
<div class="side-nav-brand"> <div class="side-nav-brand">
<img src="/assets/youreka-logo.png" alt="YOUreka" width="40" height="40"> <img src="/assets/youreka-logo.png" alt="YOUreka" width="56" height="56">
<span>YOUreka Tools</span>
</div> </div>
<cds-side-nav-items> <cds-side-nav-items>
${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)} ${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)}
${session ? `${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)}
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)} ${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)}
<cds-side-nav-divider></cds-side-nav-divider> <cds-side-nav-divider></cds-side-nav-divider>` : ''}
<cds-side-nav-link href="/auth/logout" data-signout>${SIDE_NAV_ICONS.logout}Sign out</cds-side-nav-link> ${sideLink('/help', 'Help', 'help', SIDE_NAV_ICONS.help)}
${sideAuth}
</cds-side-nav-items> </cds-side-nav-items>
</cds-side-nav> </cds-side-nav>
<cds-modal id="signout-modal" size="xs" aria-label="Sign out of YOUreka Tools"> ${session ? `<cds-modal id="signout-modal" size="xs" aria-label="Sign out of YOUreka Tools">
<cds-modal-header> <cds-modal-header>
<cds-modal-close-button></cds-modal-close-button> <cds-modal-close-button></cds-modal-close-button>
<cds-modal-label>Account</cds-modal-label> <cds-modal-label>Account</cds-modal-label>
@@ -159,8 +179,8 @@ const appHeader = (session, active) => {
</cds-modal-header> </cds-modal-header>
<cds-modal-body>You will need to authenticate again to use the tools.</cds-modal-body> <cds-modal-body>You will need to authenticate again to use the tools.</cds-modal-body>
<cds-modal-footer> <cds-modal-footer>
<cds-modal-footer-button kind="secondary" data-modal-close>Cancel</cds-modal-footer-button> <cds-modal-footer-button kind="secondary" data-modal-close><svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M17.4141 16L24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16z"/></svg>Cancel</cds-modal-footer-button>
<cds-modal-footer-button kind="danger" id="signout-confirm">Sign out</cds-modal-footer-button> <cds-modal-footer-button kind="danger" id="signout-confirm"><svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M6,30H18a2.0023,2.0023,0,0,0,2-2V25H18v3H6V4H18V7h2V4a2.0023,2.0023,0,0,0-2-2H6A2.0023,2.0023,0,0,0,4,4V28A2.0023,2.0023,0,0,0,6,30Z"/><path d="M20.586 20.586L24.172 17 10 17 10 15 24.172 15 20.586 11.414 22 10 28 16 22 22 20.586 20.586z"/></svg>Sign out</cds-modal-footer-button>
</cds-modal-footer> </cds-modal-footer>
</cds-modal> </cds-modal>
<script> <script>
@@ -183,7 +203,7 @@ const appHeader = (session, active) => {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire(); else wire();
})(); })();
</script> </script>` : ''}
`; `;
}; };
@@ -192,8 +212,53 @@ const SIDE_NAV_ICONS = {
crop: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M25,20H23V9H12V7H23a2,2,0,0,1,2,2Z"/><path d="M9,23V2H7V7H2V9H7V23a2,2,0,0,0,2,2H23v5h2V25h5V23Z"/></svg>', crop: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M25,20H23V9H12V7H23a2,2,0,0,1,2,2Z"/><path d="M9,23V2H7V7H2V9H7V23a2,2,0,0,0,2,2H23v5h2V25h5V23Z"/></svg>',
fingerprint: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>', fingerprint: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>',
logout: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6,30H18a2.0023,2.0023,0,0,0,2-2V25H18v3H6V4H18V7h2V4a2.0023,2.0023,0,0,0-2-2H6A2.0023,2.0023,0,0,0,4,4V28A2.0023,2.0023,0,0,0,6,30Z"/><path d="M20.586 20.586L24.172 17 10 17 10 15 24.172 15 20.586 11.414 22 10 28 16 22 22 20.586 20.586z"/></svg>', logout: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6,30H18a2.0023,2.0023,0,0,0,2-2V25H18v3H6V4H18V7h2V4a2.0023,2.0023,0,0,0-2-2H6A2.0023,2.0023,0,0,0,4,4V28A2.0023,2.0023,0,0,0,6,30Z"/><path d="M20.586 20.586L24.172 17 10 17 10 15 24.172 15 20.586 11.414 22 10 28 16 22 22 20.586 20.586z"/></svg>',
login: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26,30H14a2,2,0,0,1-2-2V25h2v3H26V4H14V7H12V4a2,2,0,0,1,2-2H26a2,2,0,0,1,2,2V28A2,2,0,0,1,26,30Z"/><path d="M14.59 20.59L18.17 17 4 17 4 15 18.17 15 14.59 11.41 16 10 22 16 16 22 14.59 20.59z"/></svg>',
help: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,26A12,12,0,1,1,28,16,12,12,0,0,1,16,28Z"/><path d="M17,8H15.5A4.49,4.49,0,0,0,11,12.5V13h2v-.5A2.5,2.5,0,0,1,15.5,10H17a2.5,2.5,0,0,1,0,5H15v4.5h2V17a4.5,4.5,0,0,0,0-9Z"/></svg>',
home: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16.6123,2.2138a1.01,1.01,0,0,0-1.2427,0L1,13.4194l1.2427,1.5717L4,13.6209V26a2.0041,2.0041,0,0,0,2,2H26a2.0037,2.0037,0,0,0,2-2V13.63L29.7573,15,31,13.4282ZM18,26H14V18h4Zm2,0V18a2.0023,2.0023,0,0,0-2-2H14a2.002,2.002,0,0,0-2,2v8H6V12.0615l10-7.79,10,7.8005V26Z"/></svg>',
}; };
/**
* Landing-page body: the tool tiles when signed in, a sign-in call to action
* when not. Both are injected server-side into index.html's `<!--landing-view-->`
* marker so the two states never share markup or flash the wrong one.
*/
const LANDING_TOOLS = `
<div class="tool-grid">
<cds-clickable-tile href="/cropper">
<div class="tool-card">
<span class="tool-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
</span>
<h3>Image Cropper</h3>
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
<span class="go">Open Image Cropper
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</span>
</div>
</cds-clickable-tile>
<cds-clickable-tile href="/fingerprint">
<div class="tool-card">
<span class="tool-icon">
<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>
<h3>Fingerprint Agent</h3>
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
<span class="go">Open Fingerprint Agent
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</span>
</div>
</cds-clickable-tile>
</div>`;
const LANDING_SIGNIN = `
<div class="card message-card narrow">
<div class="message-icon info">${ICONS.lock}</div>
<h1>Sign in required</h1>
<p class="muted">Sign in with your YOUreka account to open the Image Cropper and Fingerprint Agent.</p>
<a class="btn btn-primary" href="/auth/login">Sign in with YOUreka<svg width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M26,30H14a2,2,0,0,1-2-2V25h2v3H26V4H14V7H12V4a2,2,0,0,1,2-2H26a2,2,0,0,1,2,2V28A2,2,0,0,1,26,30Z"/><path d="M14.59 20.59L18.17 17 4 17 4 15 18.17 15 14.59 11.41 16 10 22 16 16 22 14.59 20.59z"/></svg></a>
</div>`;
// ── app factory ────────────────────────────────────────────────────────────── // ── app factory ──────────────────────────────────────────────────────────────
export function createApp(overrides = {}) { export function createApp(overrides = {}) {
const cfg = { const cfg = {
@@ -237,7 +302,19 @@ export function createApp(overrides = {}) {
}); });
// ── static assets (no session work per asset) ───────────────────────────── // ── static assets (no session work per asset) ─────────────────────────────
app.use(express.static(path.join(__dirname, 'public'), { index: false })); // assets only; '/' is session-aware // Versioned asset URLs are safe to cache hard; the .html templates that also
// live under /assets must never be cached (they are read server-side anyway).
app.use(
express.static(path.join(__dirname, 'public'), {
index: false,
setHeaders: (res, filePath) => {
res.setHeader(
'Cache-Control',
filePath.endsWith('.html') ? 'no-store' : 'public, max-age=31536000, immutable'
);
},
})
); // assets only; '/' is session-aware
// ── session middleware: recover / refresh the YOUreka Auth session ──────── // ── session middleware: recover / refresh the YOUreka Auth session ────────
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
@@ -324,7 +401,7 @@ export function createApp(overrides = {}) {
lastName: data.lastName ?? '', lastName: data.lastName ?? '',
phone: data.phone ?? '', phone: data.phone ?? '',
}); });
res.redirect('/fingerprint'); res.redirect('/');
} catch { } catch {
errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.'); errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
} }
@@ -387,9 +464,11 @@ export function createApp(overrides = {}) {
.set('Vary', 'Cookie') .set('Vary', 'Cookie')
.type('html') .type('html')
.send( .send(
html versionAssets(
.replace('<!--app-header-->', appHeader(session, active)) html
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`) .replace('<!--app-header-->', appHeader(session, active))
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
)
); );
}); });
}; };
@@ -403,10 +482,20 @@ export function createApp(overrides = {}) {
.set('Cache-Control', 'no-store') .set('Cache-Control', 'no-store')
.set('Vary', 'Cookie') .set('Vary', 'Cookie')
.type('html') .type('html')
.send(html.replace('<!--app-header-->', appHeader(req.session, null))); .send(
versionAssets(
html
.replace('<!--app-header-->', appHeader(req.session, 'home'))
.replace('<!--landing-view-->', req.session ? LANDING_TOOLS : LANDING_SIGNIN)
)
);
}); });
}); });
app.get('/help', (req, res) => {
renderPage(res, 'help.html', req.session, 'help');
});
app.get('/auth/session', requireApi, async (req, res, next) => { app.get('/auth/session', requireApi, async (req, res, next) => {
try { try {
const { codes, checkedAt } = await domainContext(req); const { codes, checkedAt } = await domainContext(req);
+84 -21
View File
@@ -112,7 +112,7 @@ async function login(base) {
assert.equal(loginRes.status, 302); assert.equal(loginRes.status, 302);
const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual); const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
assert.equal(callbackRes.status, 302); assert.equal(callbackRes.status, 302);
assert.equal(callbackRes.headers.get('location'), '/fingerprint'); assert.equal(callbackRes.headers.get('location'), '/');
const session = cookieValue(callbackRes, 'session'); const session = cookieValue(callbackRes, 'session');
const profile = cookieValue(callbackRes, 'user_profile'); const profile = cookieValue(callbackRes, 'user_profile');
assert.ok(session, 'session cookie set'); assert.ok(session, 'session cookie set');
@@ -121,14 +121,19 @@ async function login(base) {
return `session=${session}; user_profile=${profile}`; return `session=${session}; user_profile=${profile}`;
} }
test('landing page links both tools', async () => { test('signed-out landing prompts sign-in; signed-in landing links both tools', async () => {
const { base, stop } = await bootApp(); const { base, stop } = await bootApp();
try { try {
const res = await fetch(`${base}/`); const anon = await (await fetch(`${base}/`)).text();
const html = await res.text(); // no tools until authenticated — a call to action instead
assert.equal(res.status, 200); assert.match(anon, /href="\/auth\/login">Sign in with YOUreka</);
assert.match(html, /href="\/cropper"/); assert.ok(!anon.includes('href="/cropper"'), 'no cropper tile when signed out');
assert.match(html, /href="\/fingerprint"/); assert.ok(!anon.includes('href="/fingerprint"'), 'no fingerprint tile when signed out');
const cookies = await login(base);
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
assert.match(authed, /href="\/cropper"/);
assert.match(authed, /href="\/fingerprint"/);
} finally { } finally {
await stop(); await stop();
} }
@@ -350,28 +355,86 @@ test('landing header is server-rendered for the session', async () => {
const anon = await (await fetch(`${base}/`)).text(); const anon = await (await fetch(`${base}/`)).text();
assert.match(anon, /href="\/auth\/login">Sign in</); assert.match(anon, /href="\/auth\/login">Sign in</);
assert.ok(!anon.includes('<!--app-header-->'), 'header marker is replaced'); assert.ok(!anon.includes('<!--app-header-->'), 'header marker is replaced');
// tools live in the header nav and in the mobile side nav // signed out: no service links, but Help and Sign in are always there
assert.match(anon, /cds-header-nav-item href="\/cropper"/); assert.ok(!anon.includes('cds-header-nav-item href="/cropper"'), 'no cropper in header nav when signed out');
assert.match(anon, /cds-header-nav-item href="\/fingerprint"/); assert.ok(!anon.includes('cds-header-nav-item href="/fingerprint"'), 'no fingerprint in header nav when signed out');
assert.match(anon, /cds-side-nav-link href="\/cropper"/); assert.match(anon, /cds-header-nav-item href="\/help"/);
assert.match(anon, /cds-side-nav-divider/); assert.match(anon, /cds-side-nav-link href="\/help"/);
// side nav items carry Carbon icons in the title-icon slot // the divider only groups the services, so it is absent when signed out
assert.match(anon, /cds-side-nav-link href="\/cropper"[^>]*><svg slot="title-icon"/); assert.ok(!anon.includes('cds-side-nav-divider'), 'no side nav divider without services');
assert.match(anon, /cds-side-nav-link href="\/fingerprint"[^>]*><svg slot="title-icon"/); assert.ok(!anon.includes('header-nav-divider'), 'no header divider without services');
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"[^>]*><svg slot="title-icon"/); // no identity or sign-out when signed out
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"/); assert.ok(!anon.includes('user-chip'), 'no user chip when signed out');
assert.ok(!anon.includes('data-signout'), 'no sign-out when signed out');
assert.ok(!anon.includes('signout-modal'), 'no sign-out modal when signed out');
assert.match(anon, /youreka-logo\.png/); assert.match(anon, /youreka-logo\.png/);
assert.match(anon, /header-menu-button/); assert.match(anon, /header-menu-button/);
// favicon is the Youreka logo; sign-out is confirmed via a Carbon modal // the side nav brand is just the logo — no repeated "YOUreka Tools" text
assert.match(anon, /<div class="side-nav-brand">\s*<img[^>]*>\s*<\/div>/);
// favicon is the Youreka logo
assert.match(anon, /rel="icon"[^>]*youreka-favicon\.png/); assert.match(anon, /rel="icon"[^>]*youreka-favicon\.png/);
assert.match(anon, /data-signout/);
assert.match(anon, /cds-modal id="signout-modal"/);
assert.match(anon, /id="signout-confirm"/);
const cookies = await login(base); const cookies = await login(base);
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text(); const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
assert.match(authed, /Ada/); assert.match(authed, /Ada/);
assert.match(authed, /user-chip/);
assert.match(authed, /href="\/auth\/logout"[^>]*>Sign out</); assert.match(authed, /href="\/auth\/logout"[^>]*>Sign out</);
// signed in: services live in the header nav and in the mobile side nav
assert.match(authed, /cds-header-nav-item href="\/cropper"/);
assert.match(authed, /cds-header-nav-item href="\/fingerprint"/);
assert.match(authed, /cds-header-nav-item href="\/help"/);
assert.match(authed, /cds-header-nav-item href="\/auth\/logout" data-signout/);
assert.match(authed, /cds-side-nav-link href="\/cropper"/);
assert.match(authed, /cds-side-nav-link href="\/help"/);
assert.match(authed, /cds-side-nav-divider/);
assert.match(authed, /header-nav-divider/);
// side nav items carry Carbon icons in the title-icon slot
assert.match(authed, /cds-side-nav-link href="\/cropper"[^>]*><svg slot="title-icon"/);
assert.match(authed, /cds-side-nav-link href="\/fingerprint"[^>]*><svg slot="title-icon"/);
assert.match(authed, /cds-side-nav-link href="\/help"[^>]*><svg slot="title-icon"/);
assert.match(authed, /cds-side-nav-link href="\/auth\/logout"[^>]*><svg slot="title-icon"/);
// sign-out is confirmed via a Carbon modal
assert.match(authed, /data-signout/);
assert.match(authed, /cds-modal id="signout-modal"/);
assert.match(authed, /id="signout-confirm"/);
} finally {
await stop();
}
});
test('help page is public and documents each service', async () => {
const { base, stop } = await bootApp();
try {
const res = await fetch(`${base}/help`);
assert.equal(res.status, 200);
const html = await res.text();
assert.ok(!html.includes('<!--app-header-->'), 'header marker is replaced');
assert.match(html, /cds-accordion-item title="Image Cropper"/);
assert.match(html, /cds-accordion-item title="Fingerprint Agent"/);
assert.match(html, /accordion\.min\.js/);
assert.match(html, /cds-header-nav-item href="\/help" aria-current="page"/);
assert.match(html, /cds-side-nav-link href="\/help" aria-current="page"/);
} finally {
await stop();
}
});
test('asset URLs are versioned per deployment; HTML is never cached', async () => {
const { base, stop } = await bootApp();
try {
const res = await fetch(`${base}/`);
const html = await res.text();
assert.equal(res.headers.get('cache-control'), 'no-store');
assert.match(html, /\/assets\/style\.css\?v=[a-z0-9]+/);
assert.match(html, /\/assets\/youreka-favicon\.png\?v=[a-z0-9]+/);
// versioned assets may be cached hard...
const css = await fetch(`${base}/assets/style.css?v=x`);
assert.equal(css.status, 200);
assert.match(css.headers.get('cache-control'), /immutable/);
// ...but the raw .html templates must not be
const tpl = await fetch(`${base}/assets/help.html`);
assert.equal(tpl.headers.get('cache-control'), 'no-store');
} finally { } finally {
await stop(); await stop();
} }