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:
+112
-23
@@ -31,6 +31,18 @@ export const DOMAINS = [
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Express does not parse cookies; this is all we need.
|
||||
@@ -66,7 +78,7 @@ function errorPage(res, status, title, message, actions = {}) {
|
||||
res
|
||||
.status(status)
|
||||
.type('html')
|
||||
.send(`<!doctype html>
|
||||
.send(versionAssets(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -90,7 +102,7 @@ function errorPage(res, status, title, message, actions = {}) {
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
</html>`));
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
@@ -122,10 +134,13 @@ const appHeader = (session, active) => {
|
||||
const sideLink = (href, label, key, icon) =>
|
||||
`<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 right = session
|
||||
? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span>
|
||||
<a class="linklike" href="/auth/logout" data-signout>Sign out</a>`
|
||||
: '<a class="linklike" href="/auth/login">Sign in</a>';
|
||||
// Auth is a menu item now, so the header's right side only carries identity.
|
||||
const headerAuth = session
|
||||
? '<cds-header-nav-item href="/auth/logout" data-signout>Sign out</cds-header-nav-item>'
|
||||
: '<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 `
|
||||
<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>
|
||||
@@ -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>
|
||||
</cds-header-name>
|
||||
<cds-header-nav>
|
||||
${toolLink('/cropper', 'Image Cropper', 'cropper')}
|
||||
${toolLink('/', 'Home', 'home')}
|
||||
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
|
||||
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
|
||||
<span class="header-nav-divider" aria-hidden="true"></span>` : ''}
|
||||
${toolLink('/help', 'Help', 'help')}
|
||||
${headerAuth}
|
||||
</cds-header-nav>
|
||||
${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-side-nav aria-label="Tools" id="side-nav">
|
||||
<div class="side-nav-brand">
|
||||
<img src="/assets/youreka-logo.png" alt="YOUreka" width="40" height="40">
|
||||
<span>YOUreka Tools</span>
|
||||
<img src="/assets/youreka-logo.png" alt="YOUreka" width="56" height="56">
|
||||
</div>
|
||||
<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)}
|
||||
<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>
|
||||
<cds-side-nav-divider></cds-side-nav-divider>` : ''}
|
||||
${sideLink('/help', 'Help', 'help', SIDE_NAV_ICONS.help)}
|
||||
${sideAuth}
|
||||
</cds-side-nav-items>
|
||||
</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-close-button></cds-modal-close-button>
|
||||
<cds-modal-label>Account</cds-modal-label>
|
||||
@@ -159,8 +179,8 @@ const appHeader = (session, active) => {
|
||||
</cds-modal-header>
|
||||
<cds-modal-body>You will need to authenticate again to use the tools.</cds-modal-body>
|
||||
<cds-modal-footer>
|
||||
<cds-modal-footer-button kind="secondary" data-modal-close>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="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"><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>
|
||||
<script>
|
||||
@@ -183,7 +203,7 @@ const appHeader = (session, active) => {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', 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>',
|
||||
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>',
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
export function createApp(overrides = {}) {
|
||||
const cfg = {
|
||||
@@ -237,7 +302,19 @@ export function createApp(overrides = {}) {
|
||||
});
|
||||
|
||||
// ── 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 ────────
|
||||
app.use(async (req, res, next) => {
|
||||
@@ -324,7 +401,7 @@ export function createApp(overrides = {}) {
|
||||
lastName: data.lastName ?? '',
|
||||
phone: data.phone ?? '',
|
||||
});
|
||||
res.redirect('/fingerprint');
|
||||
res.redirect('/');
|
||||
} catch {
|
||||
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')
|
||||
.type('html')
|
||||
.send(
|
||||
html
|
||||
.replace('<!--app-header-->', appHeader(session, active))
|
||||
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
|
||||
versionAssets(
|
||||
html
|
||||
.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('Vary', 'Cookie')
|
||||
.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) => {
|
||||
try {
|
||||
const { codes, checkedAt } = await domainContext(req);
|
||||
|
||||
Reference in New Issue
Block a user