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
+142 -72
View File
@@ -1,78 +1,125 @@
# YOUreka Tools — Fingerprint Service # YOUreka Tools
A small Express app that hosts the YOUreka tools landing page and the The web front-end for YOUreka ID field operations. A small Express app that
**Fingerprint Agent** enrollment wizard. Fingerprint capture is performed by hosts an authentication-aware landing page, an **Image Cropper**, a
the local [`fpagent`](../fingerprint-agent) binary (Socket.IO on **Fingerprint Agent** enrollment wizard, and a public **Help** page.
`127.0.0.1:9000`); this app provides the login (YOUreka Auth), the permitted
domain selection, and the a2loft integration that stores the enrolled Fingerprint captures run through the local [`fpagent`](../fingerprint-agent)
fingerprints. 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 ## Layout
``` ```
tools/ 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) ├── 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/ ├── public/
│ ├── index.html # landing page (Image Cropper + Fingerprint Agent) │ ├── index.html # landing page shell (tiles injected per session)
│ └── assets/ │ └── assets/
│ ├── style.css │ ├── style.css # shared Carbon-styled layout + g100 theme
│ └── fp/ # /fingerprint enrollment wizard (Vue 2 via CDN) │ ├── help.html # /help docs page
└── test/ # node --test suite (fake portal / PocketBase / a2loft) │ ├── 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 ## Running
Prerequisites: Node.js 22+, `fpagent` installed and running on this laptop Prerequisites: Node.js 22+. For the Fingerprint Agent you also need `fpagent`
(see the fingerprint-agent repository for installation), the U.are.U 4500 installed and running on this laptop (see the fingerprint-agent repository) with
scanner attached. the U.are.U 4500 scanner attached.
```sh ```sh
cd tools cd tools
npm install npm install
npm start # → http://localhost:4173 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 must allow the tools origin. On a dev machine run it with:
> `fpagent -allowed-origins http://localhost:4173` > `fpagent -allowed-origins http://localhost:4173`
## How enrollment works ## How it works
1. **Sign in** — YOUreka Auth (portal web-auth redirect). The PocketBase ### Authentication and sessions
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).
## 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 ### Permissions
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the domain - **Fingerprint captures** require `capture_biometric` per domain. The hardcoded
catalog. Change them there; there are no environment variables or config catalog (UNICAL, Metropolitan Hotel, UCTH, UNICAL Radio International,
files. 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 Image Cropper
The `/cropper` page (Smart Photo Editor) lets operators review, crop and save The `/cropper` page (Smart Photo Editor) lets operators work through the a2loft
identity photos. It reuses the YOUreka session for identity, requires the `id-images` queue: **Load next queued** or **Load by upload ID**, review the
`crop_image` permission on `domain:idid` for every proxied action (verified record, crop the photo, optionally **Clean background**, then accept or reject
against the portal's OpenFGA endpoint — live on save, 5-minute cached on with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
reads), and keeps the a2loft OTP requirement only on the save step. 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 They live in dotenvx-encrypted files at the repository root — `.env` for
development, `.env.production` for deployment: development, `.env.production` for deployment:
@@ -83,16 +130,46 @@ NODERED_HTTP_USERNAME=… NODERED_HTTP_PASSWORD=…
CARVEKIT_API_KEY=… CARVEKIT_API_KEY=…
``` ```
Without them the rest of the app works and `/cropper` routes answer `503`. Without them the rest of the app works and `/cropper` answers `503`. The private
The private decryption keys sit in `.env.keys` (gitignored, never commit it). decryption keys sit in `.env.keys` (gitignored, never commit it).
```sh ```sh
dotenvx run -f .env -- node tools/server.js # local, from the repo root 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 dotenvx set MINIO_SECRET_KEY "<value>" -f .env # add/rotate a value
``` ```
The Docker image contains the encrypted `.env.production` and runs through ## The Fingerprint Agent
`dotenvx`; only the private key is supplied at runtime (never baked in):
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 ```sh
docker build -t yreka-tools . 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 `docker inspect`; on a shared host prefer an env file with restrictive
permissions (`--env-file deploy.keys`) or your platform's secret store. 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` Behind a TLS-terminating proxy the app honours `X-Forwarded-Proto`
(`trust proxy` is enabled), so session cookies get the `Secure` flag (`trust proxy` is enabled), so session cookies get the `Secure` flag
automatically. automatically.
Field walkthrough (real portal, PocketBase, a2loft, and U.are.U 4500): login → ## Tests
permitted-domain filtering → staff lookup → two scanner captures with cancel
path → save → reset, all verified end to end via Playwright.
```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>
<div class="row-gap"> <div class="row-gap">
<cds-button v-bind:disabled="busy.load" v-on:click="loadPicture('next')"> <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-if="!busy.load">Load next queued</span>
<span v-else>Loading&hellip;</span> <span v-else>Loading&hellip;</span>
</cds-button> </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="secondary" v-bind:disabled="busy.load" v-on:click="loadPicture('by-id')">
<cds-button kind="ghost" v-if="message && message.message" v-on:click="reset">Clear</cds-button> <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> </div>
</section> </section>
@@ -98,7 +105,10 @@
<div class="croppa-pad mt-1"> <div class="croppa-pad mt-1">
<img :src="cleanedImage" alt="cleaned preview"> <img :src="cleanedImage" alt="cleaned preview">
</div> </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> </template>
</div> </div>
@@ -113,6 +123,7 @@
</select> </select>
</div> </div>
<cds-button v-if="requiresCleaning === 'Yes'" v-bind:disabled="busy.clean" v-on:click="cleanImage"> <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-if="!busy.clean">Clean background</span>
<span v-else>Cleaning&hellip;</span> <span v-else>Cleaning&hellip;</span>
</cds-button> </cds-button>
@@ -121,17 +132,27 @@
<hr class="mt-2"> <hr class="mt-2">
<div class="row-gap" v-if="!reviewed"> <div class="row-gap" v-if="!reviewed">
<cds-button v-on:click="reviewed = true; accepted = true">Accept picture</cds-button> <cds-button v-on:click="reviewed = true; accepted = true">
<cds-button kind="danger" v-on:click="reviewed = true; accepted = false">Reject picture</cds-button> <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>
<div v-if="reviewed && accepted" class="mt-2"> <div v-if="reviewed && accepted" class="mt-2">
<div class="row-gap" style="align-items:flex-end;"> <div class="row-gap" style="align-items:flex-end;">
<cds-button v-bind:disabled="!canSave" v-on:click="saveChanges"> <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-if="!busy.save">Crop and upload picture</span>
<span v-else>Saving&hellip;</span> <span v-else>Saving&hellip;</span>
</cds-button> </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>
</div> </div>
@@ -154,11 +175,18 @@
</div> </div>
<div class="row-gap mt-2"> <div class="row-gap mt-2">
<cds-button kind="danger" v-bind:disabled="!canReview" v-on:click="reviewPicture()"> <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-if="!busy.review">Send review</span>
<span v-else>Sending&hellip;</span> <span v-else>Sending&hellip;</span>
</cds-button> </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-if="!message.message.process" v-bind:disabled="!canReview" v-on:click="removeFromQueue">
<cds-button kind="ghost" v-on:click="reviewed = false">Change review</cds-button> <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> </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"> <input id="otpLookup" class="cds--text-input" v-model.trim="otpLookup" v-on:keyup.enter="lookup" autocomplete="one-time-code">
</div> </div>
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup"> <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-if="!busy.lookup">Get details</span>
<span v-else>Checking&hellip;</span> <span v-else>Checking&hellip;</span>
</cds-button> </cds-button>
<cds-button kind="ghost" v-bind:disabled="!refreshEnabled" v-on:click="refreshDomains" <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"> 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-if="!refreshing">Refresh domains</span>
<span v-else>Refreshing&hellip;</span> <span v-else>Refreshing&hellip;</span>
</cds-button> </cds-button>
@@ -106,12 +108,17 @@
<div class="row-gap mt-1"> <div class="row-gap mt-1">
<cds-button v-if="fingers[finger].status !== 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)"> <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' }} {{ fingers[finger].status === 'failed' ? 'Retry capture' : 'Capture fingerprint' }}
</cds-button> </cds-button>
<cds-button kind="secondary" v-if="fingers[finger].status === 'done'" v-bind:disabled="capturing !== null" v-on:click="capture(finger)"> <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 Recapture
</cds-button> </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> </div>
</div> </div>
@@ -130,6 +137,7 @@
<input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code"> <input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code">
</div> </div>
<cds-button v-bind:disabled="!canSave" v-on:click="save"> <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-if="!busy.save">Save enrollment</span>
<span v-else>Saving&hellip;</span> <span v-else>Saving&hellip;</span>
</cds-button> </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 /* 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();
} }