diff --git a/tools/public/assets/cropper/app.js b/tools/public/assets/cropper/app.js index 6f2a415..a3b18aa 100644 --- a/tools/public/assets/cropper/app.js +++ b/tools/public/assets/cropper/app.js @@ -54,10 +54,6 @@ Vue.config.ignoredElements = [/^cds-/]; }, computed: { - initials: function () { - var n = (this.session && (this.session.firstName || this.session.phone)) || '?'; - return n.slice(0, 2).toUpperCase(); - }, canReview: function () { if (this.busy.review) return false; if (this.accepted) return true; diff --git a/tools/public/assets/cropper/index.html b/tools/public/assets/cropper/index.html index 9c30128..478e385 100644 --- a/tools/public/assets/cropper/index.html +++ b/tools/public/assets/cropper/index.html @@ -12,6 +12,7 @@ + @@ -23,23 +24,7 @@
- +
diff --git a/tools/public/assets/fp/app.js b/tools/public/assets/fp/app.js index 3e67330..b8e5362 100644 --- a/tools/public/assets/fp/app.js +++ b/tools/public/assets/fp/app.js @@ -41,10 +41,6 @@ Vue.config.ignoredElements = [/^cds-/]; }, computed: { - initials: function () { - var n = (this.session && (this.session.firstName || this.session.phone)) || '?'; - return n.slice(0, 2).toUpperCase(); - }, maxStage: function () { return MAX_STAGE; }, bothCaptured: function () { return this.fingers['right-thumb'].status === 'done' && @@ -139,17 +135,27 @@ Vue.config.ignoredElements = [/^cds-/]; }); }, + /** The agent status tag lives in the server-rendered header. */ + renderAgentTag: function () { + var el = document.getElementById('agent-status'); + if (!el) return; + el.textContent = this.agent.connected ? 'Agent connected' : 'Agent offline'; + el.setAttribute('type', this.agent.connected ? 'green' : 'red'); + el.setAttribute('title', this.agent.version ? 'fpagent ' + this.agent.version : ''); + }, + connectAgent: function () { var self = this; this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 }); - this.socket.on('connect', function () { self.agent.connected = true; }); - this.socket.on('disconnect', function () { self.agent.connected = false; }); - this.socket.on('connect_error', function () { self.agent.connected = false; }); + this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); }); + this.socket.on('disconnect', function () { self.agent.connected = false; self.renderAgentTag(); }); + this.socket.on('connect_error', function () { self.agent.connected = false; self.renderAgentTag(); }); this.socket.on('agent-hello', function (data) { self.agent.version = (data && data.version) || ''; self.agent.captureAgentId = (data && data.captureAgentId) || ''; + self.renderAgentTag(); }); this.socket.on('fprint-enroll-status', function (data) { diff --git a/tools/public/assets/fp/index.html b/tools/public/assets/fp/index.html index 1ea4a8c..5d54658 100644 --- a/tools/public/assets/fp/index.html +++ b/tools/public/assets/fp/index.html @@ -9,6 +9,7 @@ + @@ -16,25 +17,7 @@
- +
diff --git a/tools/public/assets/style.css b/tools/public/assets/style.css index 7bba41c..3d54d35 100644 --- a/tools/public/assets/style.css +++ b/tools/public/assets/style.css @@ -26,33 +26,33 @@ cds-toast:not(:defined), cds-toast-item:not(:defined), cds-progress-bar:not(:def a real box so grid columns and click targets work correctly. */ cds-tile, cds-clickable-tile { display: block; } -/* ── header (Carbon ui-shell look) ──────────────────────────────────────── */ -.nav { - background: #161616; - height: 48px; - padding: 0; +/* ── header + side nav (Carbon ui-shell) ────────────────────────────────── */ +cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; } +.brand-logo { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 4px; padding: 2px; margin-right: 0.5rem; } +.brand-logo img { display: block; } +.brand-suffix { color: var(--cds-text-secondary, #525252); font-weight: 400; } +.header-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; padding-right: 1rem; } +.header-agent { margin-left: 1rem; } +/* mobile: account actions live in the side nav, not the header */ +@media (max-width: 1055px) { + .header-right, .header-agent { display: none; } } -.nav .container { display: flex; align-items: center; justify-content: space-between; gap: 1rem; height: 100%; max-width: none; padding: 0 1rem; } -.brand { display: inline-flex; align-items: center; gap: 0.6rem; font-weight: 600; color: #f4f4f4; font-size: 14px; } -.brand:hover { color: #f4f4f4; } -.brand small { color: #c6c6c6; font-weight: 400; } -.brand-mark { - width: 28px; height: 28px; border-radius: 4px; - background: #0f62fe; color: #fff; - display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 auto; -} -.brand-mark svg { width: 16px; height: 16px; } -.nav-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } -.nav-right .linklike { color: #c6c6c6; font-size: 13px; padding: 0.4rem 0.6rem; } -.nav-right .linklike:hover { color: #f4f4f4; background: #353535; } -.user-chip { display: inline-flex; align-items: center; gap: 0.45rem; color: #c6c6c6; 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; +/* collapsed side nav must never swallow taps meant for the header */ +cds-side-nav { pointer-events: none; } +cds-side-nav[expanded] { pointer-events: auto; } +/* the side nav is a mobile affordance — desktop uses the header nav */ +@media (min-width: 1056px) { + cds-side-nav { display: none; } } +.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; } +.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 */ +.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 img { border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 6px; padding: 2px; display: block; } /* ── layout ─────────────────────────────────────────────────────────────── */ .container { width: 100%; max-width: 1136px; margin: 0 auto; padding: 0 1.5rem; } @@ -127,8 +127,6 @@ textarea.form-control { height: auto; padding: 0.6rem 0.75rem; } .btn-danger-ghost { background: transparent; border: none; color: var(--cds-button-danger-primary, #da1e28); } .btn[disabled], .btn.disabled { opacity: 0.5; pointer-events: none; } .btn-block { display: flex; width: 100%; align-items: center; justify-content: center; } -.linklike { background: none; border: none; padding: 0; color: var(--cds-link, #0f62fe); font-weight: 400; cursor: pointer; font-size: 13px; font-family: inherit; } -.linklike[disabled] { color: var(--cds-text-disabled, #c6c6c6); cursor: default; } /* cds-button inside flex rows */ cds-button { min-width: 0; } diff --git a/tools/public/assets/youreka-logo.png b/tools/public/assets/youreka-logo.png new file mode 100644 index 0000000..3f89d0f Binary files /dev/null and b/tools/public/assets/youreka-logo.png differ diff --git a/tools/public/index.html b/tools/public/index.html index cc902e8..1c5d306 100644 --- a/tools/public/index.html +++ b/tools/public/index.html @@ -8,23 +8,15 @@ + + - +
diff --git a/tools/server.js b/tools/server.js index 0e87a4b..f93fa14 100644 --- a/tools/server.js +++ b/tools/server.js @@ -75,19 +75,7 @@ function errorPage(res, status, title, message, actions = {}) { - + ${appHeader(null, null)}
${ICONS[icon] || ICONS.alert}
@@ -118,6 +106,50 @@ function esc(value) { .replace(/'/g, '''); }; +/** + * The application header and mobile side nav (Carbon ui-shell), shared by + * every page. `session` is present when the operator is signed in; `active` + * marks the current tool. Rendered server-side so pages need no client + * session fetch and hold a single header definition. + */ +const appHeader = (session, active) => { + const toolLink = (href, label, key) => + `${label}`; + const sideLink = (href, label, key) => + `${label}`; + const name = session ? (session.firstName || session.phone || '') : ''; + const right = session + ? `${esc(name.trim().slice(0, 2).toUpperCase())}${esc(name)} + Sign out` + : 'Sign in'; + return ` + + + + YOUreka Tools + + + ${toolLink('/cropper', 'Image Cropper', 'cropper')} + ${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')} + + ${active === 'fingerprint' ? 'Agent offline' : ''} +
${right}
+
+ +
+ YOUreka + YOUreka Tools +
+ + ${sideLink('/cropper', 'Image Cropper', 'cropper')} + ${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')} + + Sign out + +
+`; +}; + // ── app factory ────────────────────────────────────────────────────────────── export function createApp(overrides = {}) { const cfg = { @@ -300,7 +332,7 @@ export function createApp(overrides = {}) { * client never has to fetch it (and never flashes a loading state). * Per-user content — must not be cached. */ - const renderPage = (res, file, session) => { + const renderPage = (res, file, session, active) => { fs.readFile(path.join(__dirname, 'public', 'assets', file), 'utf8', (error, html) => { if (error) return errorPage(res, 500, 'Something went wrong', 'The page could not be loaded.'); const data = session @@ -310,21 +342,24 @@ export function createApp(overrides = {}) { .set('Cache-Control', 'no-store') .set('Vary', 'Cookie') .type('html') - .send(html.replace('', ` \n`)); + .send( + html + .replace('', appHeader(session, active)) + .replace('', ` \n`) + ); }); }; + + app.get('/', (req, res, next) => { fs.readFile(path.join(__dirname, 'public', 'index.html'), 'utf8', (error, html) => { if (error) return next(error); - const nav = req.session - ? `${esc((req.session.firstName || req.session.phone || '?').trim().slice(0, 2).toUpperCase())}${esc(req.session.firstName || req.session.phone || '')}Sign out` - : 'Sign in'; res .set('Cache-Control', 'no-store') .set('Vary', 'Cookie') .type('html') - .send(html.replace('', nav)); + .send(html.replace('', appHeader(req.session, null))); }); }); @@ -363,7 +398,7 @@ export function createApp(overrides = {}) { // ── wizard page (session required) ──────────────────────────────────────── app.get('/fingerprint', requirePage, async (req, res, next) => { try { - renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true })); + renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }), 'fingerprint'); } catch (e) { next(e); } @@ -399,11 +434,12 @@ export function createApp(overrides = {}) { next(); }; app.get('/cropper', requirePage, pageCropGate, (req, res, next) => { - renderPage(res, 'cropper/index.html', { - phone: req.session.phone, - firstName: req.session.firstName, - lastName: req.session.lastName, - }); + renderPage( + res, + 'cropper/index.html', + { phone: req.session.phone, firstName: req.session.firstName, lastName: req.session.lastName }, + 'cropper' + ); }); const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients(); if (cropperClients) { diff --git a/tools/test/server.test.js b/tools/test/server.test.js index 55080b0..1a9f909 100644 --- a/tools/test/server.test.js +++ b/tools/test/server.test.js @@ -317,7 +317,15 @@ test('landing header is server-rendered for the session', async () => { try { const anon = await (await fetch(`${base}/`)).text(); assert.match(anon, /href="\/auth\/login">Sign in'), 'marker is replaced'); + assert.ok(!anon.includes(''), 'header marker is replaced'); + // tools live in the header nav and in the mobile side nav + assert.match(anon, /cds-header-nav-item href="\/cropper"/); + assert.match(anon, /cds-header-nav-item href="\/fingerprint"/); + assert.match(anon, /cds-side-nav-link href="\/cropper"/); + assert.match(anon, /cds-side-nav-divider/); + assert.match(anon, /cds-side-nav-link href="\/auth\/logout"/); + assert.match(anon, /youreka-logo\.png/); + assert.match(anon, /header-menu-button/); const cookies = await login(base); const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();