Compare commits

..
3 Commits
Author SHA1 Message Date
adebisi-fa 4d006e361f docs: rewrite README for the current project status
Restructure around the four pages, the nav, auth/sessions, permissions,
the a2loft proxy, configuration, deployment (incl. asset cache-busting)
and tests; fix the duplicate Docker heading and the stale test count.
2026-10-01 14:39:57 +01:00
adebisi-fa a218835a1c 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).
2026-10-01 14:39:50 +01:00
adebisi-fa 134edb6ebd feat(ui): add Carbon icons to all tool buttons
Cropper: next, search, reset, trash-can, magic-wand, checkmark, misuse,
crop, undo, send, subtract.
Fingerprint Agent: search, renew, fingerprint-recognition, close, save.
2026-10-01 14:39:42 +01:00
8 changed files with 476 additions and 160 deletions
+141 -71
View File
@@ -1,32 +1,64 @@
# YOUreka Tools — Fingerprint Service
# YOUreka Tools
A small Express app that hosts the YOUreka tools landing page and the
**Fingerprint Agent** enrollment wizard. Fingerprint capture is performed by
the local [`fpagent`](../fingerprint-agent) binary (Socket.IO on
`127.0.0.1:9000`); this app provides the login (YOUreka Auth), the permitted
domain selection, and the a2loft integration that stores the enrolled
fingerprints.
The web front-end for YOUreka ID field operations. A small Express app that
hosts an authentication-aware landing page, an **Image Cropper**, a
**Fingerprint Agent** enrollment wizard, and a public **Help** page.
Fingerprint captures run through the local [`fpagent`](../fingerprint-agent)
binary (Socket.IO on `127.0.0.1:9000`); this app handles login (YOUreka Auth),
per-domain permissions, and the a2loft integration that reads and stores records.
It is stateless — the operator's PocketBase session lives in an httpOnly cookie,
so the container can run anywhere while fpagent stays on the field laptop.
## Tools at a glance
| Page | Path | Access | Purpose |
| --- | --- | --- | --- |
| Landing | `/` | public | Links the tools when signed in; shows a sign-in call to action when not |
| Fingerprint Agent | `/fingerprint` | signed in | Enroll right-thumb and right-index fingerprints via the attached scanner |
| Image Cropper | `/cropper` | signed in + `crop_image` | Review, crop, clean and save identity photos from the a2loft queue |
| Help | `/help` | public | Intro plus a per-tool how-to (Carbon accordion) |
## Navigation
Every page shares one Carbon ui-shell header and mobile side nav, rendered
server-side:
- **Home** → `/`
- **Image Cropper**, **Fingerprint Agent** → shown only when signed in
- a divider, then **Help**
- **Sign in** (anonymous) or **Sign out** (signed in; confirmed via a modal)
The landing page hides the two service tiles until you sign in, and the sign-in
callback returns you to `/`.
## Layout
```
tools/
├── server.js # Express app: routes, session middleware, proxies
├── server.js # Express app: routes, session middleware, caching, proxies
├── yrka-auth.js # YOUreka Auth cookie session → PocketBase (authRefresh)
├── permit.js # OpenFGA domain permission checks + 5-minute cache
├── permit.js # OpenFGA permission checks + 5-minute cache
├── cropper.js # /cropper router (queue, review, save) — injectable clients
├── public/
│ ├── index.html # landing page (Image Cropper + Fingerprint Agent)
│ ├── index.html # landing page shell (tiles injected per session)
│ └── assets/
│ ├── style.css
│ └── fp/ # /fingerprint enrollment wizard (Vue 2 via CDN)
└── test/ # node --test suite (fake portal / PocketBase / a2loft)
│ ├── style.css # shared Carbon-styled layout + g100 theme
│ ├── help.html # /help docs page
│ ├── fp/ # /fingerprint wizard (Vue 2 via CDN)
│ ├── cropper/ # /cropper page (Vue 2 + vue-croppa via CDN)
│ └── youreka-logo.png, youreka-favicon.png
└── test/ # node --test suite (fake portal / PocketBase / a2loft), 48 tests
```
`openspec/` at the repository root holds the design specs and the in-flight
change proposals (see [Design docs](#design-docs)).
## Running
Prerequisites: Node.js 22+, `fpagent` installed and running on this laptop
(see the fingerprint-agent repository for installation), the U.are.U 4500
scanner attached.
Prerequisites: Node.js 22+. For the Fingerprint Agent you also need `fpagent`
installed and running on this laptop (see the fingerprint-agent repository) with
the U.are.U 4500 scanner attached.
```sh
cd tools
@@ -34,45 +66,60 @@ npm install
npm start # → http://localhost:4173
```
Open http://localhost:4173 and sign in with your YOUreka account.
Open http://localhost:4173 and sign in with your YOUreka account. Started this
way (no cropper credentials) everything works except `/cropper`, which answers
`503`.
To run locally **with** the cropper, supply the credentials via dotenvx from the
repository root:
```sh
dotenvx run -f .env -- node tools/server.js
```
> fpagent must allow the tools origin. On a dev machine run it with:
> `fpagent -allowed-origins http://localhost:4173`
## How enrollment works
## How it works
1. **Sign in** — YOUreka Auth (portal web-auth redirect). The PocketBase
session is kept in an httpOnly cookie and refreshed server-side.
2. **Pick a domain** — the hardcoded catalog (UNICAL, Metropolitan Hotel, UCTH,
UNICAL Radio International, YOUreka) is filtered against the same portal
OpenFGA endpoint fpagent uses (`capture_biometric` per domain). Results are
cached for 5 minutes; the **Refresh domains** control unlocks once the
cache expires. fpagent re-authorizes every capture regardless.
3. **Locate the staff profile** — reference + OTP, validated by
`apis.a2loft.net` through a same-origin proxy (the operator's phone comes
from the session).
4. **Capture** — right thumb and right index finger, one capture at a time,
with per-scan progress and cancel; the PocketBase token, domain and
reference travel with every `fprint-enroll` to fpagent.
5. **Save** — both templates are posted to a2loft (with a fresh OTP).
### Authentication and sessions
## Configuration
Sign-in is the YOUreka portal web-auth flow: the app opens a portal auth session
with a local callback URL, redirects the operator to the portal, and on callback
redeems the session id for a PocketBase cookie. That value is stored under the
app's own httpOnly `session` cookie (30-day max age) alongside the operator's
profile. On every request the session is recovered server-side and refreshed
when the token is invalid or expires within 5 minutes; an unrecoverable session
is treated as anonymous and the stale cookie cleared.
Everything is hardcoded in `tools/server.js` by design — port `4173`, portal
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the domain
catalog. Change them there; there are no environment variables or config
files.
### Permissions
- **Fingerprint captures** require `capture_biometric` per domain. The hardcoded
catalog (UNICAL, Metropolitan Hotel, UCTH, UNICAL Radio International,
YOUreka) is filtered against the portal's OpenFGA endpoint, cached for 5
minutes; the **Refresh domains** control unlocks once the cache expires.
fpagent re-authorizes every capture regardless.
- **Image Cropper** actions require `crop_image`. Reads may use the 5-minute
cached verdict; the save always verifies live.
### a2loft proxy
Staff lookup and the fingerprint save are proxied to `https://apis.a2loft.net`
under same-origin routes. The proxy attaches the operator's phone from the
server-side session and forwards the OTP supplied by the page; the operator
never sends credentials to a third party directly. The cropper's save call
instead authenticates with the session token (`Bearer`) after a live permission
check.
## The Image Cropper
The `/cropper` page (Smart Photo Editor) lets operators review, crop and save
identity photos. It reuses the YOUreka session for identity, requires the
`crop_image` permission on `domain:idid` for every proxied action (verified
against the portal's OpenFGA endpoint — live on save, 5-minute cached on
reads), and keeps the a2loft OTP requirement only on the save step.
The `/cropper` page (Smart Photo Editor) lets operators work through the a2loft
`id-images` queue: **Load next queued** or **Load by upload ID**, review the
record, crop the photo, optionally **Clean background**, then accept or reject
with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
live and uploads through the queue.
Its service credentials are the sole environment variables this app uses.
Its service credentials are the **only** environment variables this app uses.
They live in dotenvx-encrypted files at the repository root — `.env` for
development, `.env.production` for deployment:
@@ -83,16 +130,46 @@ NODERED_HTTP_USERNAME=… NODERED_HTTP_PASSWORD=…
CARVEKIT_API_KEY=…
```
Without them the rest of the app works and `/cropper` routes answer `503`.
The private decryption keys sit in `.env.keys` (gitignored, never commit it).
Without them the rest of the app works and `/cropper` answers `503`. The private
decryption keys sit in `.env.keys` (gitignored, never commit it).
```sh
dotenvx run -f .env -- node tools/server.js # local, from the repo root
dotenvx set MINIO_SECRET_KEY "<value>" -f .env # add/rotate a value
```
The Docker image contains the encrypted `.env.production` and runs through
`dotenvx`; only the private key is supplied at runtime (never baked in):
## The Fingerprint Agent
The `/fingerprint` wizard walks the operator through:
1. **Locate profile** — pick a permitted domain, enter the staff reference and
OTP, and look up the record through the a2loft proxy.
2. **Capture** — right thumb and right index finger, one capture at a time, with
per-scan progress and cancel. The PocketBase token, domain and reference
travel with every `fprint-enroll` to fpagent.
3. **Save** — both templates are posted to a2loft with a fresh OTP.
## Configuration
Everything except the cropper credentials is hardcoded in `tools/server.js` by
design — port `4173`, portal `https://portal.yrka.app`, PocketBase
`https://pb.itcass.net`, a2loft `https://apis.a2loft.net`, agent
`http://127.0.0.1:9000`, and the domain catalog. Change them there; there are no
other environment variables or config files.
## Deployment
**Cache busting.** Each process start generates a deploy id
(`DEPLOY_VERSION`) that is appended to every `/assets/...` URL in served pages
(`?v=…`), so browsers fetch fresh CSS/JS/images after a deploy. Versioned assets
are cached hard (`max-age=1y, immutable`); the `.html` templates and all page
routes are served `no-store`.
**Stateless container.** The container only serves the web front-end; fpagent
keeps running on the operator's laptop (the wizard reaches it at
`http://127.0.0.1:9000` from the browser, wherever the container lives). The
image bakes in the encrypted `.env.production` and runs through `dotenvx`; only
the private key is supplied at runtime (never baked in):
```sh
docker build -t yreka-tools .
@@ -106,32 +183,25 @@ undecryptable credentials. Note that `--env` values are visible to
`docker inspect`; on a shared host prefer an env file with restrictive
permissions (`--env-file deploy.keys`) or your platform's secret store.
## Tests
```sh
cd tools
npm test # node --test — 35 tests, no network or hardware required
```
## Docker
## Docker
The app is stateless, so the container only serves the web front-end; fpagent
keeps running on the operator's laptop (the wizard connects to
`http://127.0.0.1:9000` from the browser, wherever this container lives).
```sh
docker build -t yreka-tools .
docker run -d -p 4173:4173 --name yreka-tools yreka-tools
```
Behind a TLS-terminating proxy the app honours `X-Forwarded-Proto`
(`trust proxy` is enabled), so session cookies get the `Secure` flag
automatically.
Field walkthrough (real portal, PocketBase, a2loft, and U.are.U 4500): login →
permitted-domain filtering → staff lookup → two scanner captures with cancel
path → save → reset, all verified end to end via Playwright.
## Tests
```sh
cd tools
npm test # node --test — 48 tests, no network or hardware required
```
The suite covers the auth round-trip, session refresh, permission caching, the
cropper router and token auth, and the a2loft proxies against fakes. A full
field walkthrough (real portal, PocketBase, a2loft, and U.are.U 4500) — login →
domain filtering → staff lookup → two captures with cancel path → save → reset —
has been verified end to end via Playwright.
## Design docs
`openspec/` holds the source-of-truth specs under `openspec/specs/`
(`tools-app`, `fingerprint-wizard`, `image-cropper`) and change proposals under
`openspec/changes/`. See `openspec/config.yaml` for the workflow.
+36 -8
View File
@@ -51,11 +51,18 @@
</div>
<div class="row-gap">
<cds-button v-bind:disabled="busy.load" v-on:click="loadPicture('next')">
<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="M16 8L14.57 9.393 20.15 15 8 15 8 17 20.15 17 14.57 22.573 16 24 24 16 16 8z"/><path d="M16,30A14,14,0,1,1,30,16,14.0158,14.0158,0,0,1,16,30ZM16,4A12,12,0,1,0,28,16,12.0137,12.0137,0,0,0,16,4Z"/></svg>
<span v-if="!busy.load">Load next queued</span>
<span v-else>Loading&hellip;</span>
</cds-button>
<cds-button kind="secondary" v-bind:disabled="busy.load" v-on:click="loadPicture('by-id')">Load by upload ID</cds-button>
<cds-button kind="ghost" v-if="message && message.message" v-on:click="reset">Clear</cds-button>
<cds-button kind="secondary" v-bind:disabled="busy.load" v-on:click="loadPicture('by-id')">
<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="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
Load by upload ID
</cds-button>
<cds-button kind="ghost" v-if="message && message.message" v-on:click="reset">
<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="M18,28A12,12,0,1,0,6,16v6.2L2.4,18.6,1,20l6,6,6-6-1.4-1.4L8,22.2V16H8A10,10,0,1,1,18,26Z"/></svg>
Clear
</cds-button>
</div>
</section>
@@ -98,7 +105,10 @@
<div class="croppa-pad mt-1">
<img :src="cleanedImage" alt="cleaned preview">
</div>
<cds-button kind="ghost" class="mt-1" v-on:click="cleanedImage = null">Remove cleaned image</cds-button>
<cds-button kind="ghost" class="mt-1" v-on:click="cleanedImage = null">
<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="M12 12H14V24H12zM18 12H20V24H18z"/><path d="M4 6V8H6V28a2 2 0 002 2H24a2 2 0 002-2V8h2V6zM8 28V8H24V28zM12 2H20V4H12z"/></svg>
Remove cleaned image
</cds-button>
</template>
</div>
@@ -113,6 +123,7 @@
</select>
</div>
<cds-button v-if="requiresCleaning === 'Yes'" v-bind:disabled="busy.clean" v-on:click="cleanImage">
<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="M29.4141,24,12,6.5859a2.0476,2.0476,0,0,0-2.8281,0l-2.586,2.586a2.0021,2.0021,0,0,0,0,2.8281L23.999,29.4141a2.0024,2.0024,0,0,0,2.8281,0l2.587-2.5865a1.9993,1.9993,0,0,0,0-2.8281ZM8,10.5859,10.5859,8l5,5-2.5866,2.5869-5-5ZM25.4131,28l-11-10.999L17,14.4141l11,11Z"/><path d="M2.586 14.586H5.414V17.414H2.586z" transform="rotate(-45 4 16)"/><path d="M14.586 2.586H17.414V5.414H14.586z" transform="rotate(-45 16 4)"/><path d="M2.586 2.586H5.414V5.414H2.586z" transform="rotate(-45 4 4)"/></svg>
<span v-if="!busy.clean">Clean background</span>
<span v-else>Cleaning&hellip;</span>
</cds-button>
@@ -121,17 +132,27 @@
<hr class="mt-2">
<div class="row-gap" v-if="!reviewed">
<cds-button v-on:click="reviewed = true; accepted = true">Accept picture</cds-button>
<cds-button kind="danger" v-on:click="reviewed = true; accepted = false">Reject picture</cds-button>
<cds-button v-on:click="reviewed = true; accepted = true">
<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="M13 24L4 15 5.414 13.586 13 21.171 26.586 7.586 28 9 13 24z"/></svg>
Accept picture
</cds-button>
<cds-button kind="danger" v-on:click="reviewed = true; accepted = false">
<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="M16,2C8.2,2,2,8.2,2,16s6.2,14,14,14s14-6.2,14-14S23.8,2,16,2z M16,28C9.4,28,4,22.6,4,16S9.4,4,16,4s12,5.4,12,12S22.6,28,16,28z"/><path d="M21.4 23L16 17.6 10.6 23 9 21.4 14.4 16 9 10.6 10.6 9 16 14.4 21.4 9 23 10.6 17.6 16 23 21.4z"/></svg>
Reject picture
</cds-button>
</div>
<div v-if="reviewed && accepted" class="mt-2">
<div class="row-gap" style="align-items:flex-end;">
<cds-button v-bind:disabled="!canSave" v-on:click="saveChanges">
<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="M25,20H23V9H12V7H23a2,2,0,0,1,2,2Z"/><path d="M9,23V2H7V7H2V9H7V23a2,2,0,0,0,2,2H23v5h2V25h5V23Z"/></svg>
<span v-if="!busy.save">Crop and upload picture</span>
<span v-else>Saving&hellip;</span>
</cds-button>
<cds-button kind="ghost" v-on:click="reviewed = false">Change review</cds-button>
<cds-button kind="ghost" v-on:click="reviewed = false">
<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="M20,10H7.8149l3.5874-3.5859L10,5,4,11,10,17l1.4023-1.4146L7.8179,12H20a6,6,0,0,1,0,12H12v2h8a8,8,0,0,0,0-16Z"/></svg>
Change review
</cds-button>
</div>
</div>
@@ -154,11 +175,18 @@
</div>
<div class="row-gap mt-2">
<cds-button kind="danger" v-bind:disabled="!canReview" v-on:click="reviewPicture()">
<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="M27.45,15.11l-22-11a1,1,0,0,0-1.08.12,1,1,0,0,0-.33,1L7,16,4,26.74A1,1,0,0,0,5,28a1,1,0,0,0,.45-.11l22-11a1,1,0,0,0,0-1.78Zm-20.9,10L8.76,17H18V15H8.76L6.55,6.89,24.76,16Z"/></svg>
<span v-if="!busy.review">Send review</span>
<span v-else>Sending&hellip;</span>
</cds-button>
<cds-button kind="ghost" v-if="!message.message.process" v-bind:disabled="!canReview" v-on:click="removeFromQueue">Dequeue only</cds-button>
<cds-button kind="ghost" v-on:click="reviewed = false">Change review</cds-button>
<cds-button kind="ghost" v-if="!message.message.process" v-bind:disabled="!canReview" v-on:click="removeFromQueue">
<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="M8 15H24V17H8z"/></svg>
Dequeue only
</cds-button>
<cds-button kind="ghost" v-on:click="reviewed = false">
<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="M20,10H7.8149l3.5874-3.5859L10,5,4,11,10,17l1.4023-1.4146L7.8179,12H20a6,6,0,0,1,0,12H12v2h8a8,8,0,0,0,0-16Z"/></svg>
Change review
</cds-button>
</div>
</div>
</div>
+9 -1
View File
@@ -59,11 +59,13 @@
<input id="otpLookup" class="cds--text-input" v-model.trim="otpLookup" v-on:keyup.enter="lookup" autocomplete="one-time-code">
</div>
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup">
<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="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
<span v-if="!busy.lookup">Get details</span>
<span v-else>Checking&hellip;</span>
</cds-button>
<cds-button kind="ghost" v-bind:disabled="!refreshEnabled" v-on:click="refreshDomains"
v-bind:title="refreshEnabled ? 'Re-check which domains you may capture for' : 'Permissions were checked recently — refresh unlocks ' + refreshEtaLabel">
<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="M12 10H6.78A11 11 0 0127 16h2A13 13 0 006 7.68V4H4v8h8zM20 22h5.22A11 11 0 015 16H3a13 13 0 0023 8.32V28h2V20H20z"/></svg>
<span v-if="!refreshing">Refresh domains</span>
<span v-else>Refreshing&hellip;</span>
</cds-button>
@@ -106,12 +108,17 @@
<div class="row-gap mt-1">
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
<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="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>
{{ fingers[finger].status === 'failed' ? 'Retry capture' : 'Capture fingerprint' }}
</cds-button>
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)">
<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="M12 10H6.78A11 11 0 0127 16h2A13 13 0 006 7.68V4H4v8h8zM20 22h5.22A11 11 0 015 16H3a13 13 0 0023 8.32V28h2V20H20z"/></svg>
Recapture
</cds-button>
<cds-button kind="danger" v-if="capturing === finger" v-on:click="cancelCapture">Cancel</cds-button>
<cds-button kind="danger" v-if="capturing === finger" v-on:click="cancelCapture">
<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-button>
</div>
</div>
</div>
@@ -130,6 +137,7 @@
<input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code">
</div>
<cds-button v-bind:disabled="!canSave" v-on:click="save">
<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="M27.71,9.29l-5-5A1,1,0,0,0,22,4H6A2,2,0,0,0,4,6V26a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V10A1,1,0,0,0,27.71,9.29ZM12,6h8v4H12Zm8,20H12V18h8Zm2,0V18a2,2,0,0,0-2-2H12a2,2,0,0,0-2,2v8H6V6h4v4a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V6.41l4,4V26Z"/></svg>
<span v-if="!busy.save">Save enrollment</span>
<span v-else>Saving&hellip;</span>
</cds-button>
+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
a real box so grid columns and click targets work correctly. */
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) ────────────────────────────────── */
cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; }
.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-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 .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; }
.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 { 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 ─────────────────────────────────────────────────────────────── */
.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 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) ──────────────────────────────── */
label { font-weight: 400; font-size: 12px; color: var(--cds-text-secondary, #525252); margin-bottom: 0.25rem; display: block; }
.form-control {
@@ -131,9 +141,10 @@ textarea.form-control { height: auto; padding: 0.6rem 0.75rem; }
.btn {
border-radius: 0; font-weight: 400; font-size: 14px;
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;
}
.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: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); }
@@ -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-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.info { background: var(--cds-button-primary, #0f62fe); }
.message-card .btn { margin-top: 0.6rem; min-width: 200px; }
+2 -28
View File
@@ -22,36 +22,10 @@
<main class="container">
<div class="hero">
<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 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>
<!--landing-view-->
</main>
<footer class="foot">
+109 -20
View File
@@ -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(
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);
+84 -21
View File
@@ -112,7 +112,7 @@ async function login(base) {
assert.equal(loginRes.status, 302);
const callbackRes = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
assert.equal(callbackRes.status, 302);
assert.equal(callbackRes.headers.get('location'), '/fingerprint');
assert.equal(callbackRes.headers.get('location'), '/');
const session = cookieValue(callbackRes, 'session');
const profile = cookieValue(callbackRes, 'user_profile');
assert.ok(session, 'session cookie set');
@@ -121,14 +121,19 @@ async function login(base) {
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();
try {
const res = await fetch(`${base}/`);
const html = await res.text();
assert.equal(res.status, 200);
assert.match(html, /href="\/cropper"/);
assert.match(html, /href="\/fingerprint"/);
const anon = await (await fetch(`${base}/`)).text();
// no tools until authenticated — a call to action instead
assert.match(anon, /href="\/auth\/login">Sign in with YOUreka</);
assert.ok(!anon.includes('href="/cropper"'), 'no cropper tile when signed out');
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 {
await stop();
}
@@ -350,28 +355,86 @@ test('landing header is server-rendered for the session', async () => {
const anon = await (await fetch(`${base}/`)).text();
assert.match(anon, /href="\/auth\/login">Sign in</);
assert.ok(!anon.includes('<!--app-header-->'), '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/);
// side nav items carry Carbon icons in the title-icon slot
assert.match(anon, /cds-side-nav-link href="\/cropper"[^>]*><svg slot="title-icon"/);
assert.match(anon, /cds-side-nav-link href="\/fingerprint"[^>]*><svg slot="title-icon"/);
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"[^>]*><svg slot="title-icon"/);
assert.match(anon, /cds-side-nav-link href="\/auth\/logout"/);
// signed out: no service links, but Help and Sign in are always there
assert.ok(!anon.includes('cds-header-nav-item href="/cropper"'), 'no cropper in header nav when signed out');
assert.ok(!anon.includes('cds-header-nav-item href="/fingerprint"'), 'no fingerprint in header nav when signed out');
assert.match(anon, /cds-header-nav-item href="\/help"/);
assert.match(anon, /cds-side-nav-link href="\/help"/);
// the divider only groups the services, so it is absent when signed out
assert.ok(!anon.includes('cds-side-nav-divider'), 'no side nav divider without services');
assert.ok(!anon.includes('header-nav-divider'), 'no header divider without services');
// no identity or sign-out when signed out
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, /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, /data-signout/);
assert.match(anon, /cds-modal id="signout-modal"/);
assert.match(anon, /id="signout-confirm"/);
const cookies = await login(base);
const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text();
assert.match(authed, /Ada/);
assert.match(authed, /user-chip/);
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 {
await stop();
}