Compare commits

..
10 Commits
Author SHA1 Message Date
adebisi-fa 3a5edbfe75 docs(openspec): cropper-token-auth field pass verified
Loaded a real upload (panel: Domain ucth · picture target core), saved with
no OTP — the proxy POSTed with the session token and no query auth, a2loft
returned ok and the panel reset.
2026-10-01 13:02:04 +01:00
adebisi-fa 796094f61b feat(cropper): save with the session token — no OTP, domain from a2loft
Consumes the a2loft cropper-token-auth change: the save now sends the
session's PocketBase token as a bearer credential and no phone/otp. The
backend authorizes with OpenFGA crop_image (domain:<code> -> domain:idid),
so an operator no longer needs super-user rights in the profile's domain —
which is what made saves fail with a blanket 'Invalid OTP specified.'.

- the save's domain comes from a2loft's own record of the picture
  (dims/picture/{uploadId} -> domainCode), falling back to the queue label,
  guarded by a path-safety pattern instead of the catalog check
- the 'core' -> 'uc' alias is deleted: 'core' is a2loft's own pseudo-domain
  for a profile picture (special-cased there), so rewriting it changed the
  attribution and the ID-card notification
- queue loads are enriched with the profile's domainCode; the panel shows
  the profile's domain and, when it differs, the picture target
- the cropper's OTP field, state and parameters are gone; the fingerprint
  wizard is untouched (its own OTP flow on its own endpoints)
- tests updated: bearer + no query auth, resolved domain wins, unsafe values
  refused, no OTP field served (46 pass)

Requires the a2loft change deployed first.
2026-10-01 12:37:25 +01:00
adebisi-fa 88d71ffec0 docs(cropper): explain why the queue's core label maps to uc
Evidence: GET {a2loft}/identity/{code}/units resolves domain codes — uc,
ucth, mtr, uri, yrka are valid, core answers 'Invalid domain code
specified.' The label comes from the Telegram intake hashtag, not a2loft.
2026-10-01 11:50:06 +01:00
adebisi-fa 645d3d2bc9 fix(cropper): alias the queue's core domain to uc; show server error text
Saving failed with 400 'Unknown domain.' because queue items carry the
legacy domain 'core', which the old bot mapped to 'uc' for a2loft URLs —
my domain validation (added in the review pass) rejected it. The save now
applies the same alias before validating and forwarding.

Also: the client showed axios's generic 'Request failed with status code
400' because the catch destructured error.message; all cropper handlers now
prefer the server's response.data.message (errMessage helper), so the real
reason is visible.

Verified: save with a 'core' item forwards to /identity/uc/profile/picture;
a 400 shows 'Unknown domain.'; 46 tests.
2026-10-01 10:35:02 +01:00
adebisi-fa 001290a789 feat(ui): icons for the mobile side-nav items
Crop / fingerprint-recognition / logout Carbon icons (16px, inlined into the
shared header so they inherit the theme colour) in the side-nav-link
'title-icon' slot — the slot is named title-icon, not nav-icon.
2026-10-01 06:21:27 +01:00
adebisi-fa 1f8fdb47b6 refactor(cropper): drop the cropped preview — the cleaned image is the only preview
The extra cropped-result panel duplicated what the crop pad already shows and
the cleaned image supersedes it. Removes the panel, its state, the pointer-
release refresh handlers and the app.js plumbing. The crop result is still
uploaded (croppa stays the source when nothing is cleaned).
2026-10-01 04:39:08 +01:00
adebisi-fa 95200b6abf fix(cropper): make load failures visible; loud config errors
'Load next queued' did nothing because the running server had been started
without its env file (a restart used '-f .env' from tools/, where no .env
exists), so the cropper was unconfigured and every action answered 503 —
and none of that was visible, because notifications never rendered either:

- notifications used cds-toast/cds-toast-item, which the Carbon bundle does
  not define; a cds-toast-notification element renders nothing standalone in
  this build. The cropper now uses cds-inline-notification in the fixed
  toast viewport (renders, themed, close button) like the wizard
- an unconfigured deployment now says so on the page (503 'Cropper
  unavailable') instead of rendering actions that all fail, and logs a
  startup warning naming the missing credentials
- tests: notifications use a registered element; unconfigured page 503
2026-10-01 04:36:31 +01:00
adebisi-fa cb5b151988 fix(cropper): render reason checkboxes, gate the Others field, add cropped preview
Three defects from the Carbon rewrite, found while fixing the review panel:

- reason checkboxes were invisible: Carbon's .cds--checkbox input is
  visually clipped and needs the .cds--form-item/.cds--checkbox-wrapper +
  .cds--checkbox-label markup to draw the box
- 'Others (please specify)' now appears only when 'Others not specified.'
  is ticked, and clears when unticked (hidden text must not be submitted);
  the textarea gets Carbon's __wrapper so it has a proper height
- new 'Cropped preview' between the original and cleaned previews, so the
  operator sees what will be uploaded; refreshed on pointer release over the
  pad and after load/clean
- REGRESSION: Vue.use(Croppa) had been dropped — the crop canvas never
  rendered, and saving only worked because the cleaned image bypassed
  generateDataUrl. Restored.
- REGRESSION: the OTP field bound v-model="otpSave" while the data property
  is "otp", so Vue's render threw ReferenceError and froze the panel as
  soon as accept was clicked (the OTP could never be entered). Bound to otp;
  the croppa instance is now reached via $refs.croppa (the v-model object is
  not always the live instance)
- after a successful save the review panel stayed open: reset() recreated an
  empty message object, which the template treats as truthy. message is null
  when idle now.

45 tests pass; save round-trip and reject/Others flow verified in the browser.
2026-10-01 04:26:26 +01:00
adebisi-fa 2e4a51d018 feat(ui): Youreka favicon, logout confirmation, nav active states, brand alignment
- favicon is the Youreka logo (64px PNG generated from the source mark),
  on the tools pages and server error pages
- signing out asks first: a Carbon cds-modal (Cancel / danger Sign out)
  shared by the header and the mobile side nav; Cancel closes, Sign out
  confirms and logs out
- the current tool is now visibly selected: Carbon marks active nav via
  a[aria-current=page] INSIDE the shadow, but aria-current sits on the
  host, so the rule never matched — the header item now gets a blue
  underline and the side-nav link a blue left border (via ::part(link)
  for colour, plus the host for the marker)
- logo and wordmark are middle-aligned inside a flex brand wrapper
- tests: favicon/modal/signout-trigger markup and the active-tool marking
  on both navs (45 total)
2026-10-01 04:08:09 +01:00
adebisi-fa 17c407883d feat(ui): g100 dark theme and more space above page titles
- all pages (landing, both tools, server error pages) render on Carbon's
  g100 theme via <body class="app cds--g100">; token consumers follow
  automatically, including component shadow styles through the inherited
  --cds-* custom properties
- dark base on <html> so no light flash around the viewport
- step chips and tool icons use the brand blue: layer-selected-inverse
  inverts to a LIGHT token in g100, which had made them white-on-white
- page titles breathe: .page-head top margin 1.5rem -> 2.75rem
- new test asserting every page renders on g100 (44 total)
2026-10-01 04:01:44 +01:00
15 changed files with 464 additions and 92 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-01
@@ -0,0 +1,51 @@
# Design
## Context
See proposal.md. The a2loft `cropper-token-auth` change adds token mode to the
profile-picture endpoints: `Authorization: Bearer <pb token>`, identity from
the token's phone, authorized with OpenFGA `crop_image` on `domain:<code>` then
`domain:idid`. This change consumes it.
## Goals / Non-Goals
**Goals:** the cropper saves with the session token and no OTP; the save's
domain is a2loft's own record of the picture; the panel is truthful about the
profile's domain.
**Non-Goals:** the fingerprint wizard's OTP flow; the cropper's lookup (there
is none — pictures come from the queue); removing OTP support in a2loft.
## Decisions
1. **Token sent from the server**, never exposed to the browser beyond the
page's existing injected session: the proxy attaches
`Authorization: Bearer ${req.session.token}`.
2. **Domain from a2loft, not the client**: `dims/picture/{uploadId}` is the
record of the picture; its `domainCode` is the profile's identity domain and
its `domain` is the picture target (`core`). Sent as `domainCode`, displayed
as both. Falls back to the queue item's label when a2loft has no record
(custom ids), guarded by a path-safety pattern.
3. **The `core → uc` alias is deleted**: `core` is a2loft's own pseudo-domain
(special-cased in `getUserByPhoneAsync`/`updatePicture`), so rewriting it —
as the old bot did for its own calls — changed attribution and the
notification. Authorization no longer depends on the domain, so no alias is
needed.
4. **Queue loads are enriched** with `domainCode` for display (one extra
read-only a2loft call per picture).
## Risks / Trade-offs
- [a2loft record unavailable for an upload] → falls back to the queue label
under the path-safety check; the save still carries the token for authz.
- [Operators outside the domain's super-users] → irrelevant now: the OpenFGA
check with the `domain:idid` fallback authorizes them.
## Migration Plan
Deploy a2loft first (dual-mode, no client change), then this change; the
cropper saves without an OTP. Rollback = revert the proxy and re-add the field.
## Open Questions
None.
@@ -0,0 +1,44 @@
# Proposal: cropper-token-auth
## Why
The cropper's save proves the operator with a phone number and an OTP. a2loft
resolves that phone against the domain being saved to, so an operator without
super-user rights in that domain is rejected with a blanket `Invalid OTP
specified.` — even though they are authorized to crop images for the domain.
The a2loft endpoints now accept a PocketBase token (see the a2loft change
`cropper-token-auth`), which authorizes with OpenFGA `crop_image` and takes the
actor's identity from the token.
## What Changes
- The cropper's save posts the **session token** to a2loft instead of `phone` +
`otp`; the OTP input is removed from the UI.
- The save's domain comes from **a2loft's own picture record**
(`dims/picture/{uploadId}` → `domainCode`), falling back to the queue item's
label, and the panel shows both (the profile's domain and the picture
target).
- The `core → uc` alias is deleted: `core` is a2loft's own pseudo-domain for a
profile picture, not an alias for `uc`.
- The fingerprint wizard's staff lookup and fingerprint save keep their OTP;
only the cropper's calls change.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `image-cropper`: the save is token-authenticated (no OTP), the domain comes
from a2loft's record, and the OTP requirement is removed.
## Impact
- `tools/cropper.js`: save handler (Bearer token, no `?otp=`), domain
resolution via dims with the label as fallback, path-safety check replacing
the catalog check, queue loads enriched with the profile's domain.
- `tools/public/assets/cropper/`: the OTP field and all `otp` state removed;
the panel shows the profile's domain and the picture target.
- Requires the a2loft `cropper-token-auth` change to be deployed first.
@@ -0,0 +1,37 @@
# Spec Delta: image-cropper
## MODIFIED Requirements
### Requirement: Saving requires a live gate and an OTP
Saving a cropped picture (`/cropper/profile/picture`) SHALL verify the crop
permission live (bypassing the cache) and SHALL authenticate the operator with
the session's PocketBase token, sent to a2loft as a bearer credential; no OTP
SHALL be collected or forwarded. The picture's domain SHALL come from a2loft's
own record for the upload (`dims/picture/{uploadId}` → `domainCode`), falling
back to the queue item's label, and SHALL match a path-safe pattern before it
is used in the request. A denied save SHALL not reach a2loft.
#### Scenario: Successful save
- **WHEN** a permitted operator saves
- **THEN** the route verifies the live gate and forwards the picture to
a2loft with the session token as a bearer credential and the profile's
resolved domain
#### Scenario: Denied save does not reach a2loft
- **WHEN** the live crop check denies, or the request carries no message body
- **THEN** the route answers 403/400 and makes no a2loft call
#### Scenario: Unsafe domain values are refused
- **WHEN** neither a2loft's record nor the queue label yields a path-safe
domain
- **THEN** the save is refused with 400 and a2loft is not contacted
### Requirement: Unified login — no separate artist login
The cropper SHALL NOT implement its own login. The operator's identity comes
from the YOUreka session and is presented to a2loft as the session's PocketBase
token; the picture's own metadata carries the domain. The cropper collects no
other credential — there is no save-time OTP.
#### Scenario: Phone prefilled from the session
- **WHEN** a signed-in operator opens the cropper
- **THEN** their phone is already known to the page without any login form
@@ -0,0 +1,18 @@
# Tasks
- [x] 1. Save handler: `Authorization: Bearer <session token>`, no `?phone/otp`;
domain resolved from `dims/picture/{uploadId}` (`domainCode`) with the queue
label as fallback, guarded by a path-safety pattern; `requireOtp` and the
`core → uc` alias deleted.
- [x] 2. Queue loads enriched with the profile's `domainCode` for display;
load-by-id passes it through.
- [x] 3. UI: OTP field, state and call parameters removed; panel shows the
profile's domain and the picture target.
- [x] 4. Tests: save carries the bearer token and no query auth; the resolved
domain wins over the body; unsafe values are refused; no OTP field in the
served page; queue load exposes `domainCode` (46 tests pass).
- [x] 5. Deploy a2loft and this app, then a field pass: load a queued picture →
save with no OTP → confirm the picture lands attributed to the token's phone.
Verified: loaded upload 0b90075b-… (panel showed "Domain ucth · picture target
core"), no OTP field, save POSTed with the session token and no query auth,
answered "Saved — Picture saved for +2348068287222" and reset the panel.
+48 -15
View File
@@ -12,6 +12,12 @@ import { stripPhonePrefix, cropPermission } from './permit.js';
const require = createRequire(import.meta.url);
const QUEUE_NAME = 'id-images';
/**
* Domains are forwarded as the picture record states them. The queue's label
* (e.g. `core` from the Telegram intake) and the profile's identity domain
* (`domainCode`, e.g. `ucth`) are both legitimate values there: a2loft
* authorizes the actor with OpenFGA, not with the domain's uploader list.
*/
const BUCKET = 'files';
const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
@@ -20,6 +26,16 @@ const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
* credential is missing — the router then answers 503 and nothing else in the
* app is affected. URLs are hardcoded here; only credentials come from env.
*/
/** Credentials the cropper needs (used for the startup warning). */
export const CROPPER_CREDENTIALS = [
'MINIO_ACCESS_KEY',
'MINIO_SECRET_KEY',
'AZURE_STORAGE_CONNECTION_STRING',
'NODERED_HTTP_USERNAME',
'NODERED_HTTP_PASSWORD',
'CARVEKIT_API_KEY',
];
export function buildCropperClients(env = process.env) {
const {
MINIO_ACCESS_KEY,
@@ -116,6 +132,26 @@ export function createCropperRouter(cfg) {
const UPSTREAM_TIMEOUT_MS = 15000;
const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
/**
* The profile's identity domain as a2loft records it (`domainCode`), falling
* back to the queue item's own label. Used for the save and for display.
*/
const resolvePictureDomain = async (uploadId, fallback) => {
if (!uploadId) return fallback;
try {
const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(uploadId)}`, {
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
const body = await r.json().catch(() => null);
return (body && body.ok && body.data && body.data.domainCode) || fallback;
} catch {
return fallback;
}
};
/** Path-safety only: a2loft decides which codes exist or are authorized. */
const SAFE_DOMAIN = /^[A-Za-z0-9_-]+$/;
const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
const getMessage = () =>
@@ -131,14 +167,6 @@ export function createCropperRouter(cfg) {
)
);
const requireOtp = (req, res) => {
if (!req.query.otp) {
res.status(400).json({ ok: false, message: 'OTP is required.' });
return false;
}
return true;
};
// ── pictures ────────────────────────────────────────────────────────────
router.get('/picture/next', async (req, res) => {
let message;
@@ -156,6 +184,7 @@ export function createCropperRouter(cfg) {
} catch {
return res.json({ ok: false, message: 'Queue item is malformed.' });
}
data.domainCode = await resolvePictureDomain(data.upload_id, null);
const { messageId, popReceipt, dequeueCount } = message;
res.json({
ok: true,
@@ -171,14 +200,14 @@ export function createCropperRouter(cfg) {
const body = await r.json().catch(() => null);
const data = body && body.data;
if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' });
const { upload_id, file_name, caption, domain, raw_file_id } = data;
const { upload_id, file_name, caption, domain, raw_file_id, domainCode } = data;
return res.json({
ok: true,
data: {
message_id: 'custom',
pop_receipt: 'custom',
dequeue_count: 0,
message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false },
message: { upload_id, file_name, caption, domain, domainCode, raw_file_id, from_queue: false },
},
});
} catch (error) {
@@ -309,7 +338,6 @@ export function createCropperRouter(cfg) {
// ── save (the one a2loft call that needs the operator OTP) ──────────────
router.post('/profile/picture', async (req, res) => {
if (!requireMessageBody(req, res)) return;
if (!requireOtp(req, res)) return;
let live;
try {
live = await cropPermission({
@@ -338,17 +366,22 @@ export function createCropperRouter(cfg) {
message: { upload_id, domain, agentDeviceHash } = {},
},
} = req.body;
if (!domainCodes.has(domain)) {
// The profile's own domain comes from a2loft's record, not the client.
const urlDomain = await resolvePictureDomain(upload_id, domain);
if (!SAFE_DOMAIN.test(urlDomain || '')) {
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
}
const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
try {
const r = await fetchImpl(
`${base}/${encodeURIComponent(domain)}/profile/picture?${q.toString()}`,
`${base}/${encodeURIComponent(urlDomain)}/profile/picture`,
{
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: {
'content-type': 'application/json',
// the session token proves the operator; a2loft authorizes with OpenFGA
authorization: `Bearer ${req.session.token}`,
},
body: JSON.stringify({
pictureDataUrl,
phone,
+37 -20
View File
@@ -3,6 +3,7 @@
the save-time OTP (required by a2loft). Notifications are Carbon toasts. */
/* global Vue, axios, Croppa, customElements */
/* window.__SESSION__ is injected server-side by /cropper */
Vue.use(Croppa); // registers the <croppa> component — without this the crop UI never renders
Vue.config.ignoredElements = [/^cds-/];
(function () {
@@ -15,16 +16,13 @@ Vue.config.ignoredElements = [/^cds-/];
data: {
loading: true,
session: null,
message: {
message: { caption: null, file_url: null, domain: '' },
},
message: null, // set once a picture is loaded; null hides the review panel
agent: null,
process: null,
reviewed: false,
accepted: false,
cleanedImage: null,
requiresCleaning: '',
otp: '',
reviewOptions: [
'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
@@ -60,7 +58,21 @@ Vue.config.ignoredElements = [/^cds-/];
return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
},
canSave: function () {
return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== '';
// the session token authenticates the save; no OTP is involved
return !this.busy.save && this.requiresCleaning !== '';
},
otherOptionSelected: function () {
return this.selectedReviewOptions.indexOf('Others not specified.') >= 0;
},
croppaInstance: function () {
// the v-model object is not always the live instance; the ref is
return this.$refs.croppa || this.myCroppa;
},
},
watch: {
otherOptionSelected: function (selected) {
if (!selected) this.otherReviewOption = ''; // hidden field must not contribute
},
},
@@ -83,6 +95,12 @@ Vue.config.ignoredElements = [/^cds-/];
},
methods: {
/** axios hides the server's reason in response.data — prefer it. */
errMessage: function (error, fallback) {
return (error && error.response && error.response.data && error.response.data.message) ||
(error && error.message) || fallback;
},
toast: function (kind, title, caption) {
var self = this;
var id = ++toastSeq;
@@ -97,7 +115,7 @@ Vue.config.ignoredElements = [/^cds-/];
},
reset: function () {
this.message = { message: { caption: null, file_url: null, domain: '' } };
this.message = null;
this.agent = null;
this.process = null;
this.reviewed = false;
@@ -106,7 +124,6 @@ Vue.config.ignoredElements = [/^cds-/];
this.selectedReviewOptions = [];
this.otherReviewOption = '';
this.requiresCleaning = '';
this.otp = '';
},
loadPicture: async function (mode) {
@@ -145,8 +162,8 @@ Vue.config.ignoredElements = [/^cds-/];
);
this.agent = review.data.ok ? review.data.data : null;
}
} catch ({ message }) {
this.toast('error', 'Operation Failed', message);
} catch (error) {
this.toast('error', 'Operation Failed', this.errMessage(error, 'Could not load the picture.'));
} finally {
this.busy.load = false;
}
@@ -156,7 +173,7 @@ Vue.config.ignoredElements = [/^cds-/];
var self = this;
this.busy.clean = true;
try {
var dataUrl = this.myCroppa.generateDataUrl('image/jpeg');
var dataUrl = this.croppaInstance.generateDataUrl('image/jpeg');
var response = await fetch('/cropper/api/removebg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -174,8 +191,8 @@ Vue.config.ignoredElements = [/^cds-/];
var fr = new FileReader();
fr.onload = function () { self.cleanedImage = fr.result; };
fr.readAsDataURL(blob);
} catch ({ message }) {
this.toast('error', 'Image Cleanup Failed', message);
} catch (error) {
this.toast('error', 'Image Cleanup Failed', this.errMessage(error, 'Cleanup failed.'));
} finally {
this.busy.clean = false;
}
@@ -220,8 +237,8 @@ Vue.config.ignoredElements = [/^cds-/];
this.toast('success', 'Review Sent', 'Profile owner has been notified of the review.');
}
this.reset();
} catch ({ message }) {
this.toast('error', 'Operation Failed', message);
} catch (error) {
this.toast('error', 'Operation Failed', this.errMessage(error, 'Review failed.'));
} finally {
this.busy.review = false;
}
@@ -239,8 +256,8 @@ Vue.config.ignoredElements = [/^cds-/];
}
this.toast('success', 'Removed!', data.message || '');
this.reset();
} catch ({ message }) {
this.toast('error', 'Not Removed', message);
} catch (error) {
this.toast('error', 'Not Removed', this.errMessage(error, 'Removal failed.'));
} finally {
this.busy.review = false;
}
@@ -250,9 +267,9 @@ Vue.config.ignoredElements = [/^cds-/];
this.busy.save = true;
try {
var { data } = await axios.post(
'/cropper/profile/picture?otp=' + encodeURIComponent(this.otp),
'/cropper/profile/picture',
{
pictureDataUrl: this.cleanedImage || this.myCroppa.generateDataUrl('image/jpeg'),
pictureDataUrl: this.cleanedImage || this.croppaInstance.generateDataUrl('image/jpeg'),
phone: this.message.message.caption,
message: this.message,
requiresCleaning: this.requiresCleaning !== 'No',
@@ -264,8 +281,8 @@ Vue.config.ignoredElements = [/^cds-/];
}
this.toast('success', 'Saved', 'Picture saved for ' + this.message.message.caption);
this.reset();
} catch ({ message }) {
this.toast('error', 'Not Saved', message);
} catch (error) {
this.toast('error', 'Not Saved', this.errMessage(error, 'Save failed.'));
} finally {
this.busy.save = false;
}
+27 -18
View File
@@ -7,12 +7,13 @@
<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" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='4' fill='%230f62fe'/><path d='M12 5a6 6 0 0 0-6 6c0 2.6.8 4.1 1.5 5.2' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M15.5 19a7.5 7.5 0 0 0 3-9.7A6 6 0 0 0 7.1 7.9' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M9.5 18.5a5.5 5.5 0 0 1-.8-7.6 3.8 3.8 0 0 1 7.2 1.5 4.3 4.3 0 0 1-3.4 4.6' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.css">
<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/button.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/notification.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
@@ -22,7 +23,7 @@
.review-options .form-check { margin-bottom: 0.35rem; }
</style>
</head>
<body class="app">
<body class="app cds--g100">
<div id="app">
<!--app-header-->
@@ -36,10 +37,9 @@
<!-- notifications -->
<div class="toast-viewport" aria-live="polite">
<cds-toast>
<cds-toast-item v-for="t in toasts" v-bind:key="t.id" v-bind:kind="t.kind"
v-bind:title="t.title" v-bind:caption="t.caption" timeout="6000"></cds-toast-item>
</cds-toast>
<cds-inline-notification v-for="t in toasts" v-bind:key="t.id" v-bind:kind="t.kind"
v-bind:title="t.title" v-bind:subtitle="t.caption" low-contrast
v-on:cds-notification-closed="removeToast(t.id)"></cds-inline-notification>
</div>
<!-- 1 · load -->
@@ -69,7 +69,15 @@
<dl class="details">
<div><dt>Phone</dt><dd>{{ message.message.caption || '—' }}</dd></div>
<div><dt>Domain</dt><dd>{{ message.message.domain }}</dd></div>
<div>
<dt>Domain</dt>
<dd>
{{ message.message.domainCode || message.message.domain }}
<span v-if="message.message.domainCode && message.message.domainCode !== message.message.domain" class="faint">
&middot; picture target {{ message.message.domain }}
</span>
</dd>
</div>
<div v-if="agent"><dt>Agent</dt><dd>{{ agent.name }} <span class="faint">{{ agent.phone }}</span></dd></div>
<div v-if="message.message.process">
<dt>Process</dt>
@@ -81,7 +89,7 @@
<div class="editor-media">
<label class="cds--label">Original — crop it</label>
<div class="croppa-pad mt-1">
<croppa v-model="myCroppa" :show-remove-button="false" :width="250" :height="267.44" :quality="2">
<croppa ref="croppa" v-model="myCroppa" :show-remove-button="false" :width="250" :height="267.44" :quality="2">
<img slot="initial" crossOrigin="anonymous" :src="message.message.file_url" style="width: 300px">
</croppa>
</div>
@@ -119,10 +127,6 @@
<div v-if="reviewed && accepted" class="mt-2">
<div class="row-gap" style="align-items:flex-end;">
<div style="min-width:150px; max-width:200px;">
<label class="cds--label" for="otpSave">OTP</label>
<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="saveChanges">
<span v-if="!busy.save">Crop and upload picture</span>
<span v-else>Saving&hellip;</span>
@@ -133,15 +137,20 @@
<div v-if="reviewed && !accepted" class="mt-2">
<label class="cds--label">Please state why this picture is not good enough.</label>
<div class="review-options mt-1">
<div v-for="item in reviewOptions" v-bind:key="item.index" style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.35rem;">
<input type="checkbox" class="cds--checkbox" v-bind:id="'reason-' + item.index" v-bind:value="item.name" v-model="selectedReviewOptions">
<label v-bind:for="'reason-' + item.index" style="margin:0;" v-on:click.prevent>{{ item.name }}</label>
<div class="review-options mt-1" v-for="item in reviewOptions" v-bind:key="item.index">
<div class="cds--form-item cds--checkbox-wrapper">
<input class="cds--checkbox" type="checkbox" v-bind:id="'reason-' + item.index"
v-bind:value="item.name" v-model="selectedReviewOptions">
<label class="cds--checkbox-label" v-bind:for="'reason-' + item.index">
<span class="cds--checkbox-label-text">{{ item.name }}</span>
</label>
</div>
</div>
<div class="mt-1" style="max-width:480px;">
<div class="mt-1" style="max-width:480px;" v-if="otherOptionSelected">
<label class="cds--label" for="otherReviewOption">Others (please specify).</label>
<textarea id="otherReviewOption" rows="3" class="cds--text-area" v-model="otherReviewOption"></textarea>
<div class="cds--text-area__wrapper">
<textarea id="otherReviewOption" rows="3" class="cds--text-area" v-model="otherReviewOption"></textarea>
</div>
</div>
<div class="row-gap mt-2">
<cds-button kind="danger" v-bind:disabled="!canReview" v-on:click="reviewPicture()">
+3 -2
View File
@@ -7,15 +7,16 @@
<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" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='4' fill='%230f62fe'/><path d='M12 5a6 6 0 0 0-6 6c0 2.6.8 4.1 1.5 5.2' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M15.5 19a7.5 7.5 0 0 0 3-9.7A6 6 0 0 0 7.1 7.9' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M9.5 18.5a5.5 5.5 0 0 1-.8-7.6 3.8 3.8 0 0 1 7.2 1.5 4.3 4.3 0 0 1-3.4 4.6' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
<script src="https://cdn.socket.io/4.4.0/socket.io.min.js" integrity="sha384-1fOn6VtTq3PWwfsOrk45LnYcGosJwzMHv+Xh/Jx5303FVOXzEnw0EpLv30mtjmlj" crossorigin="anonymous"></script>
<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/button.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/notification.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/progress-bar.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
</head>
<body class="app">
<body class="app cds--g100">
<div id="app">
<!--app-header-->
+22 -6
View File
@@ -6,6 +6,7 @@
[v-cloak] { display: none; }
a { text-decoration: none; }
html { background: var(--cds-background, #161616); }
body.app {
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--cds-background, #f4f4f4);
@@ -19,7 +20,7 @@ body.app {
/* prevent FOUC on web components */
cds-button:not(:defined), cds-tag:not(:defined), cds-tile:not(:defined),
cds-clickable-tile:not(:defined), cds-inline-notification:not(:defined),
cds-toast:not(:defined), cds-toast-item:not(:defined), cds-progress-bar:not(:defined) {
cds-progress-bar:not(:defined) {
visibility: hidden;
}
/* Carbon styles the shadow anchor inside tiles, not the host — give the host
@@ -28,9 +29,23 @@ cds-tile, cds-clickable-tile { display: block; }
/* ── header + side nav (Carbon ui-shell) ────────────────────────────────── */
cds-header:not(:defined), cds-side-nav:not(:defined) { visibility: hidden; }
.brand-logo { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--cds-border-subtle, #e0e0e0); border-radius: 4px; padding: 2px; margin-right: 0.5rem; }
.brand-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 img { display: block; }
.brand-suffix { color: var(--cds-text-secondary, #525252); font-weight: 400; }
/* the current tool is marked on the hosts (aria-current sits outside the shadow,
so Carbon's own [aria-current=page] rule never matches) */
cds-header-nav-item[aria-current="page"] {
border-bottom: 2px solid var(--cds-border-interactive, #0f62fe);
font-weight: 600;
}
cds-header-nav-item[aria-current="page"]::part(link) { color: var(--cds-text-primary, #161616); }
cds-side-nav-link[aria-current="page"] {
border-left: 3px solid var(--cds-border-interactive, #0f62fe);
font-weight: 600;
}
cds-side-nav-link[aria-current="page"]::part(link) { color: var(--cds-text-primary, #161616); }
.header-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; padding-right: 1rem; }
.header-agent { margin-left: 1rem; }
/* mobile: account actions live in the side nav, not the header */
@@ -59,7 +74,7 @@ cds-side-nav[expanded] { pointer-events: auto; }
main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
.narrow { max-width: 560px; }
.center-v { display: flex; flex-direction: column; justify-content: center; min-height: 70vh; }
.page-head { margin: 1.5rem auto 1.5rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.page-head { margin: 2.75rem auto 1.5rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.page-head h1 { font-size: 1.5rem; font-weight: 600; margin: 0; letter-spacing: 0; }
.page-head .sub { color: var(--cds-text-secondary, #525252); margin: 0.25rem 0 0; font-size: 14px; }
@@ -73,7 +88,7 @@ main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
.panel-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1rem; flex-wrap: wrap; }
.step-chip {
width: 22px; height: 22px; border-radius: 2px; flex: 0 0 auto;
background: var(--cds-layer-selected-inverse, #161616); color: #f4f4f4;
background: var(--cds-button-primary, #0f62fe); color: #fff;
font-size: 12px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
}
@@ -93,7 +108,7 @@ main.container { flex: 1 0 auto; padding-top: 1.5rem; padding-bottom: 1.5rem; }
.tool-icon {
width: 40px; height: 40px; border-radius: 4px; margin: 0;
display: inline-flex; align-items: center; justify-content: center;
background: var(--cds-layer-selected-inverse, #161616); color: #fff;
background: var(--cds-button-primary, #0f62fe); color: #fff;
}
.tool-icon svg { width: 20px; height: 20px; }
.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; }
@@ -168,7 +183,8 @@ cds-button::part(button) { font-size: 14px; }
.review-options .form-check { margin-bottom: 0.35rem; }
/* ── notifications viewport (cropper toasts) ────────────────────────────── */
.toast-viewport { position: fixed; top: 3.5rem; right: 1rem; z-index: 1100; width: min(360px, calc(100vw - 2rem)); }
.toast-viewport { position: fixed; top: 3.5rem; right: 1rem; z-index: 1100; width: min(380px, calc(100vw - 2rem)); display: flex; flex-direction: column; gap: 0.5rem; }
.toast-viewport cds-inline-notification:not(:defined) { visibility: hidden; }
/* ── footer ─────────────────────────────────────────────────────────────── */
footer.foot { flex-shrink: 0; padding: 1.1rem 0 1.3rem; color: var(--cds-text-helper, #6f6f6f); font-size: 12px; border-top: 1px solid var(--cds-border-subtle, #c6c6c6); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+3 -2
View File
@@ -7,15 +7,16 @@
<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" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='4' fill='%230f62fe'/><path d='M12 5a6 6 0 0 0-6 6c0 2.6.8 4.1 1.5 5.2' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M15.5 19a7.5 7.5 0 0 0 3-9.7A6 6 0 0 0 7.1 7.9' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M9.5 18.5a5.5 5.5 0 0 1-.8-7.6 3.8 3.8 0 0 1 7.2 1.5 4.3 4.3 0 0 1-3.4 4.6' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
<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/tile.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
<style>
cds-tile:not(:defined), cds-clickable-tile:not(:defined) { visibility: hidden; }
</style>
</head>
<body class="app">
<body class="app cds--g100">
<!--app-header-->
<main class="container">
+67 -9
View File
@@ -12,7 +12,7 @@ import fs from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
import { createCropperRouter, buildCropperClients } from './cropper.js';
import { createCropperRouter, buildCropperClients, CROPPER_CREDENTIALS } from './cropper.js';
// ── Hardcoded configuration ──────────────────────────────────────────────────
export const PORT = 4173;
@@ -73,8 +73,12 @@ function errorPage(res, status, title, message, actions = {}) {
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)} · YOUreka Tools</title>
<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/modal.min.js"></script>
<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/tag.min.js"></script>
</head>
<body class="app">
<body class="app cds--g100">
${appHeader(null, null)}
<main class="container narrow center-v">
<div class="card message-card">
@@ -115,18 +119,18 @@ function esc(value) {
const appHeader = (session, active) => {
const toolLink = (href, label, key) =>
`<cds-header-nav-item href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-header-nav-item>`;
const sideLink = (href, label, key) =>
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-side-nav-link>`;
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">Sign out</a>`
<a class="linklike" href="/auth/logout" data-signout>Sign out</a>`
: '<a class="linklike" href="/auth/login">Sign in</a>';
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>
<cds-header-name href="/">
<span class="brand-logo"><img src="/assets/youreka-logo.png" alt="" width="20" height="20"></span>YOUreka<span class="brand-suffix"> Tools</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-nav>
${toolLink('/cropper', 'Image Cropper', 'cropper')}
@@ -141,15 +145,55 @@ const appHeader = (session, active) => {
<span>YOUreka Tools</span>
</div>
<cds-side-nav-items>
${sideLink('/cropper', 'Image Cropper', 'cropper')}
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}
${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">Sign out</cds-side-nav-link>
<cds-side-nav-link href="/auth/logout" data-signout>${SIDE_NAV_ICONS.logout}Sign out</cds-side-nav-link>
</cds-side-nav-items>
</cds-side-nav>
<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>
<cds-modal-heading>Sign out of YOUreka Tools?</cds-modal-heading>
</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>
</cds-modal>
<script>
// Confirm before signing out (Carbon modal)
(function () {
function wire() {
var modal = document.getElementById('signout-modal');
var confirm = document.getElementById('signout-confirm');
if (!modal || !confirm) return;
document.addEventListener('click', function (event) {
var trigger = event.target && event.target.closest ? event.target.closest('[data-signout]') : null;
if (!trigger) return;
event.preventDefault();
modal.open = true;
});
confirm.addEventListener('click', function () {
window.location.href = '/auth/logout';
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
})();
</script>
`;
};
/** Carbon icons for the mobile side nav (inlined so they inherit the theme). */
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>',
};
// ── app factory ──────────────────────────────────────────────────────────────
export function createApp(overrides = {}) {
const cfg = {
@@ -434,6 +478,14 @@ export function createApp(overrides = {}) {
next();
};
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
if (!cropperClients) {
return errorPage(
res,
503,
'Cropper unavailable',
'The image cropper is not configured on this deployment. Contact your administrator.'
);
}
renderPage(
res,
'cropper/index.html',
@@ -442,6 +494,12 @@ export function createApp(overrides = {}) {
);
});
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
if (!cropperClients) {
const missing = CROPPER_CREDENTIALS.filter((name) => !process.env[name]);
console.warn(
`[cropper] not configured — missing ${missing.join(', ')}; /cropper will answer 503`
);
}
if (cropperClients) {
app.use(
'/cropper',
+63 -19
View File
@@ -56,19 +56,27 @@ async function startFakeA2loft() {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
calls.push({
method: req.method,
url: req.url,
authorization: req.headers.authorization,
body: raw ? JSON.parse(raw) : null,
});
const json = (body) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.url.startsWith('/identity/dims/picture/')) {
// 'no-record' lets a test exercise the fallback to the queue's own label
if (req.url.includes('no-record')) return json({ ok: false, message: 'No upload.' });
return json({
ok: true,
data: {
upload_id: 'up-1',
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
caption: '08068287222',
domain: 'ucth',
domain: 'core',
domainCode: 'ucth',
raw_file_id: 'raw-1',
},
});
@@ -217,6 +225,10 @@ test('unconfigured deployment: cropper answers 503, other tools unaffected', asy
const cookies = await login(base);
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
assert.equal(res.status, 503);
// the page says so too, rather than rendering actions that all fail
const page = await fetch(`${base}/cropper`, { headers: { cookie: cookies } });
assert.equal(page.status, 503);
assert.match(await page.text(), /not configured/i);
const landing = await fetch(`${base}/`);
assert.equal(landing.status, 200);
} finally {
@@ -233,6 +245,12 @@ test('crop gate: permitted operator loads the page; denied operator does not', a
const html = await page.text();
assert.match(html, /Smart Photo Editor|Image Cropper/);
assert.match(html, /window\.__SESSION__ = \{/, 'session injected server-side');
assert.match(html, /cds-header-nav-item href="\/cropper" aria-current="page"/);
// notifications must use a component the bundle actually defines
assert.match(html, /cds-inline-notification/);
assert.ok(!/cds-toast-item/.test(html), 'cds-toast-item is not a registered element');
assert.ok(!/otpSave|otpLookup/.test(html), 'the cropper has no OTP fields');
assert.match(html, /cds-side-nav-link href="\/cropper" aria-current="page"/);
} finally {
await ok.stop();
}
@@ -327,7 +345,7 @@ test('save verifies the gate live and proxies to a2loft with session identity',
},
},
};
const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
const res = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
@@ -339,9 +357,11 @@ test('save verifies the gate live and proxies to a2loft with session identity',
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
assert.ok(save, 'a2loft save contacted');
const u = new URL(save.url, 'http://x');
// the profile's own domain comes from a2loft's record ('ucth'), not the
// queue label the browser echoed ('ucth' here) — and no OTP is involved
assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity');
assert.equal(u.searchParams.get('otp'), '778899');
assert.equal(u.search, '', 'no phone/otp query parameters');
assert.match(save.authorization, /^Bearer \S+/, 'the session token proves the operator');
assert.equal(save.body.phone, '08068287222', 'profile phone from the picture');
assert.equal(save.body.uploadId, 'up-1');
assert.ok(portal.state.verifyCalls > 0);
@@ -350,7 +370,7 @@ test('save verifies the gate live and proxies to a2loft with session identity',
}
});
test('save refuses without an OTP and without the crop permission', async () => {
test('save requires the live crop permission (no OTP involved)', async () => {
const { base, a2loft, stop } = await boot({ cropAllowed: false });
try {
const cookies = await login(base);
@@ -363,14 +383,7 @@ test('save refuses without an OTP and without the crop permission', async () =>
message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null },
},
};
const noOtp = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
// permission denial dominates; 400 (missing OTP) or 403 both satisfy the spec
assert.ok([400, 403].includes(noOtp.status), `got ${noOtp.status}`);
const denied = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
const denied = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
@@ -428,7 +441,7 @@ test('malformed request bodies are rejected, never hang the request', async () =
const { base, a2loft, stop } = await boot();
try {
const cookies = await login(base);
for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture?otp=1']) {
for (const path of ['/cropper/picture/review', '/cropper/dequeue', '/cropper/profile/picture']) {
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
@@ -458,11 +471,11 @@ test('a malformed queue item does not hang the request', async () => {
}
});
test('save rejects an unknown domain without contacting a2loft', async () => {
test('save rejects an unsafe domain and contacts no one', async () => {
const { base, a2loft, stop } = await boot();
try {
const cookies = await login(base);
const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
const res = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify({
@@ -471,12 +484,14 @@ test('save rejects an unknown domain without contacting a2loft', async () => {
message: {
pop_receipt: 'custom',
message_id: 'm-1',
message: { upload_id: 'up-1', domain: '../../admin', agentDeviceHash: 'a' },
// a2loft has no record for this upload, so the queue's label is used
// and the path-safety check rejects it
message: { upload_id: 'no-record', domain: '../../admin', agentDeviceHash: 'a' },
},
}),
});
assert.equal(res.status, 400);
assert.equal(a2loft.calls.length, 0, 'a2loft never contacted');
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'no save attempted');
} finally {
await stop();
}
@@ -502,3 +517,32 @@ test('picture fetches reject object keys outside the id-domains prefix', async (
await stop();
}
});
test("the picture's own target is forwarded when a2loft has no record", async () => {
const { base, a2loft, stop } = await boot();
try {
const cookies = await login(base);
const res = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify({
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
phone: '08068287222',
requiresCleaning: false,
message: {
pop_receipt: 'custom',
message_id: 'm-1',
message: { upload_id: 'no-record', domain: 'core', agentDeviceHash: 'a' },
},
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
// 'core' is a2loft's own pseudo-domain for a profile picture — no rewriting
assert.equal(decodeURIComponent(new URL(save.url, 'http://x').pathname), '/identity/core/profile/picture');
} finally {
await stop();
}
});
+42 -1
View File
@@ -312,6 +312,38 @@ test('injected session values cannot break out of the script tag', async () => {
}
});
test('the active tool is marked on both navigation surfaces', async () => {
const { base, stop } = await bootApp();
try {
const cookies = await login(base);
const wizard = await (await fetch(`${base}/fingerprint`, { headers: { cookie: cookies } })).text();
assert.match(wizard, /cds-header-nav-item href="\/fingerprint" aria-current="page"/);
assert.match(wizard, /cds-side-nav-link href="\/fingerprint" aria-current="page"/);
assert.ok(
!/cds-header-nav-item href="\/cropper" aria-current/.test(wizard),
'the other tool is not marked current'
);
} finally {
await stop();
}
});
test('all pages render on the g100 dark theme', async () => {
const { base, stop } = await bootApp();
try {
const cookies = await login(base);
for (const path of ['/', '/fingerprint']) {
const html = await (await fetch(`${base}${path}`, { headers: { cookie: cookies } })).text();
assert.match(html, /<body class="app cds--g100">/, `${path} uses g100`);
}
// error pages too
const errorHtml = await (await fetch(`${base}/auth/callback?sessionId=nope`, manual)).text();
assert.match(errorHtml, /<body class="app cds--g100">/);
} finally {
await stop();
}
});
test('landing header is server-rendered for the session', async () => {
const { base, stop } = await bootApp();
try {
@@ -323,14 +355,23 @@ test('landing header is server-rendered for the session', async () => {
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"/);
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
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, /href="\/auth\/logout">Sign out</);
assert.match(authed, /href="\/auth\/logout"[^>]*>Sign out</);
} finally {
await stop();
}