Compare commits
10
Commits
340d8da19d
...
3a5edbfe75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5edbfe75 | ||
|
|
796094f61b | ||
|
|
88d71ffec0 | ||
|
|
645d3d2bc9 | ||
|
|
001290a789 | ||
|
|
1f8fdb47b6 | ||
|
|
95200b6abf | ||
|
|
cb5b151988 | ||
|
|
2e4a51d018 | ||
|
|
17c407883d |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
· 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…</span>
|
||||
@@ -133,16 +137,21 @@
|
||||
|
||||
<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>
|
||||
<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()">
|
||||
<span v-if="!busy.review">Send review</span>
|
||||
|
||||
@@ -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-->
|
||||
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user