Milestone: M3 — Issuing Functionality | SOW Reference: FR1, INT2, INT6 — extends M3-02 and M3-06 | Requirement Clarity: ✅ Clear on the UI change; 🔴 security scope needs a decision before production | Dev Status: 🟡 Planned — awaiting go-ahead to execute
M3-18 Credential Preview: Wallet-Style UI + QR/Deep-Link Admin-Visibility Fix¶
Client feedback (2026-08-20)¶
"Preview: Change from technical JSON to a wallet-style credential preview." "Credential Security: QR/deep-link security and admin visibility need further review before production" — they didn't like the QR code being visible in the admin UI; it should only be visible to the candidate, via their email/claim link.
These landed as two notes but touch the same screens, so this doc covers both together.
Part A — Preview: JSON → wallet-style¶
Where it happens today: ManualCredentialForm.tsx's sticky preview panel starts out showing clean rows (Candidate, Job title, dates — lines 379–393), but the moment "Preview" is clicked and the real payload comes back from the backend, those rows are replaced by Object.entries(preview.credentialData) (same block). The real shape is just two top-level keys, type and a nested credentialSubject object (see M3-17 for the exact shape), and formatValue() (line ~98–102) renders any object value with a bare JSON.stringify() — a single unformatted line of braces and quoted keys. Same pattern, same formatValue() helper, also drives the "Payload" card on the admin credential-detail page (pages/credentials/[id].tsx:93,313–327).
Fix: replace both of those with a proper wallet-card layout. One already exists and was clearly built with exactly this look in mind — pages/claim/[token].tsx, the candidate-facing claim page:
- BrandBar (lines 76–122) — org logo/initials + name header
- A card with a navy header showing the credential-type badge and a human title
- DetailRow (lines 125–152) — icon-led rows (person, mail, business, calendar) instead of raw key/value dumps
Adapt this component (or extract a shared CredentialCard from it) to render preview.credentialData.credentialSubject in the issuance-form preview panel and the admin detail page's payload section, mapping legalEmployer.name → employer row, recipient.givenName/familyName → recipient row, role/startDate/endDate → their own rows, etc. — instead of stringifying the object. Do not include the QR code or the raw claim link in this admin-facing version — see Part B.
Part B — QR/deep-link: currently visible to admins, shouldn't be¶
Confirmed by direct code check (2026-08-20). The claim deepLinkUrl for a credential offer is used in two genuinely different contexts today, and they're not being kept apart:
- Candidate-facing, token-gated — correct as designed.
claim.controller.ts'sGET /api/v1/public/claim/:tokenendpoint (lines 82–113) returnsvnfDeepLinkonly to whoever holds the specific claim token (from the email link), andpages/claim/[token].tsxrenders the QR there. This is the intended flow — access is gated by possession of the token, not by login. - Admin-facing, authenticated-user-only — the problem. The exact same
deepLinkUrlvalue (stored on theCredentialOfferentity,credential-offer.entity.ts:120–121) is also rendered directly inside the internal admin app, with no additional gating beyond "logged in": pages/credentials/index.tsx— a QR dialog (qrDialogFor, lines 556–606) reachable from the credentials list, showing the QR (line 577), the raw URL as copyable text (line 588), and an "Open" link (line 606).pages/credentials/[id].tsx— a permanent "Claim link" card (lines 332–388) on every credential's detail page: QR code (350–355), raw URL text (366), a "Copy link" button (368–375), and an "Open" button (376–387) that opens the candidate's personal claim link directly from an admin's browser session.
So today, any authenticated user who can reach the credentials list or detail page — which, per M2-15, is currently any role, since those routes aren't role-restricted — can view, copy, and open a specific candidate's personal claim link, and see the QR meant only for that candidate's wallet app. That's what the client's feedback is flagging.
Proposed fix¶
- Remove the QR code, raw URL text, and "Copy link"/"Open" affordances from both
pages/credentials/index.tsxandpages/credentials/[id].tsx. - Replace with an action an admin should legitimately have: "Resend claim email" (trigger the existing email-send path server-side, without ever exposing the link/token to the admin's browser) and a status indicator (sent / opened / claimed / expired), which is what M3-10/M3-11 (dashboard stats, audit reporting) already need to show anyway.
- If there's a real internal need to inspect the link (e.g. support debugging a delivery issue), gate it behind a separate, explicitly audited action rather than always-visible — but default to not showing it at all unless Curo confirms that's needed.
Why this also needs a "before production" security pass, not just a UI fix¶
The client's phrasing ("need further review before production") points at more than the display bug. Two things surfaced while investigating this that are worth flagging alongside the UI fix, not fixed here:
- M3-06 is a moved-in aspirational spec, not a verified account of what's built — it documents intended token security (JWT claim tokens, 30-day expiry, rate limiting, tamper-detection hash) but that doc was written before implementation and hasn't been re-verified against the current
claim.controller.ts/claim-token-equivalent code. Before telling the client "this is production-ready," someone should re-audit the actual claim-token implementation against that spec the same way M2-15 did for roles — confirm real expiry, real rate limiting, and that a claimed/expired token can't be reused. - This is really an instance of the same unscoped-access problem already flagged in M2-15: the credentials list/detail routes have no role restriction today, so removing the QR/link display helps, but the underlying fact that any authenticated user can reach every organization's credential records at all is a separate, larger gap that M2-15 already tracks as a security fix owed "regardless of the answers" on the role matrix. Worth doing that fix in the same pass as this one, since both routes are affected.
Acceptance criteria¶
- Preview panel (issuance form) and admin credential-detail "Payload" section both render a wallet-style card, not
JSON.stringify()output - QR code and raw claim link/deep-link URL are no longer shown anywhere in the authenticated admin UI
- Admin UI offers "Resend claim email" + claim status instead of the raw link
- M3-06's claim-token security spec (expiry, rate limiting, tamper detection) is re-verified against the actual current implementation, not just the original design doc
- Credentials list/detail routes get role/org scoping (tracked jointly with M2-15's unscoped-controller fix)