From 933ba41476635ee8e8adb04ae5722dde97cd9a08 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 25 Aug 2026 12:30:57 +0000 Subject: [PATCH 1/7] feat(signup): verify identity with Fayda and prefill the form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Continue with Fayda" to the existing signup page. It is an alternative way to fill the form, not a second signup: the applicant still submits to /auth/signup-with-pwd through the same schema, the same validation and the same redirect to OTP verification, and signing up without Fayda is unchanged. The page asks the API for an authorization URL, keeps the returned handle and state in sessionStorage for the round trip, and FaydaCallbackPage hands the code back to /auth/fayda/callback. No Fayda protocol logic lives here — the PKCE verifier, the client key and the token exchange stay on the server. Once the account exists the page posts the verification token to /auth/fayda/link so the identity is recorded against it; that call is best-effort, since the account is already usable without it. Prefilled fields carry a "From Fayda" badge and stay editable, and a value that already belongs to another account is badged as such so the applicant can see which one to change rather than reading a single opaque signup error. Cancellation, an expired session, a mismatched state and an incomplete callback each get their own message. Amharic strings are a first pass and want a native speaker's review. --- apps/portal/src/app/i18n/locales/am.ts | 22 +++ apps/portal/src/app/i18n/locales/en.ts | 22 +++ apps/portal/src/app/router.tsx | 6 + libs/auth/src/index.ts | 1 + libs/auth/src/lib/pages/FaydaCallbackPage.tsx | 119 +++++++++++++ libs/auth/src/lib/pages/SignupPage.tsx | 159 +++++++++++++++++- libs/auth/src/lib/utils/fayda-session.ts | 81 +++++++++ 7 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 libs/auth/src/lib/pages/FaydaCallbackPage.tsx create mode 100644 libs/auth/src/lib/utils/fayda-session.ts diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 0ecee5cda..98673b6b7 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -602,6 +602,28 @@ export const am: Translations = { createOne: "አንድ ይፍጠሩ", }, + fayda: { + continueWith: "በፋይዳ ይቀጥሉ", + orFillManually: "ወይም መረጃዎን ራስዎ ይሙሉ", + verifiedTitle: "በፋይዳ ተረጋግጧል", + verifiedBody: "ፋይዳ ያረጋገጣቸውን መረጃዎች ሞልተናል። እባክዎ የቀሩትን መስኮች ያሟሉ።", + discard: "እነዚህን መረጃዎች አጥፍቼ ቅጹን ራሴ እሞላለሁ", + fieldVerified: "ከፋይዳ", + fieldConflict: "በሌላ መለያ ተይዟል", + conflictBody: + "አንዳንድ የተረጋገጡ መረጃዎች አስቀድሞ የሌላ መለያ ናቸው። የተመለከቱትን መስኮች ይቀይሩ ወይም ይግቡ።", + brandTitle: "በፋይዳ በማረጋገጥ ላይ", + brandSubtitle: "ማንነትዎን እስክናረጋግጥ ድረስ አንድ አፍታ።", + verifying: "የፋይዳ ማንነትዎን በማረጋገጥ ላይ…", + failedTitle: "ማረጋገጡ አልተጠናቀቀም", + backToSignup: "ወደ ምዝገባ ተመለስ", + cancelled: "የፋይዳ ማረጋገጫው ተሰርዟል። አሁንም በእጅ መመዝገብ ይችላሉ።", + rejected: "ፋይዳ ማንነትዎን ማረጋገጥ አልቻለም። እባክዎ እንደገና ይሞክሩ።", + invalidCallback: "ይህ የማረጋገጫ ሊንክ አልተሟላም። እባክዎ እንደገና ይጀምሩ።", + sessionLost: "የማረጋገጫ ክፍለ ጊዜዎ አልፏል። እባክዎ እንደገና ይጀምሩ።", + stateMismatch: "ይህ ማረጋገጫ ሊታመን አልቻለም። እባክዎ እንደገና ይጀምሩ።", + }, + signup: { usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል", nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል", diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 57fe394f3..d6d578b06 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -602,6 +602,28 @@ export const en = { createOne: 'Create one', }, + fayda: { + continueWith: 'Continue with Fayda', + orFillManually: 'or fill in your details', + verifiedTitle: 'Verified with Fayda', + verifiedBody: 'We filled in the details Fayda confirmed. Please complete the remaining fields.', + discard: 'Clear these details and fill the form myself', + fieldVerified: 'From Fayda', + fieldConflict: 'Already used by another account', + conflictBody: + 'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.', + brandTitle: 'Verifying with Fayda', + brandSubtitle: 'One moment while we confirm your identity.', + verifying: 'Verifying your Fayda identity\u2026', + failedTitle: 'Verification incomplete', + backToSignup: 'Back to sign up', + cancelled: 'Fayda verification was cancelled. You can still sign up manually.', + rejected: 'Fayda could not verify your identity. Please try again.', + invalidCallback: 'This verification link is incomplete. Please start again.', + sessionLost: 'Your verification session has expired. Please start again.', + stateMismatch: 'This verification could not be trusted. Please start again.', + }, + signup: { usernameMinLength: 'Username must be at least 3 characters', nameEnRequired: 'Name (English) is required', diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index a8c20ea4f..348b98685 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute"; import { LoginPage, SignupPage, + FaydaCallbackPage, OTPVerificationPage, ForgotPasswordPage, SetPasswordPage, @@ -67,6 +68,11 @@ export const router = createBrowserRouter([ { path: "/login", element: }, { path: "/signup", element: }, + // Where Fayda returns the applicant. Public by necessity — they have no + // account yet. It redeems the code and hands control back to /signup; the + // path must match the API's REDIRECT_URI, which is registered with Fayda. + { path: "/signup/fayda/callback", element: }, + // Completes the forgot-password flow; the reset message links here. The // IAM package generates `/reset-password` links, `/set-password` is the // first-time-credential variant — one page serves both. diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index 7d8be994b..16ad04c39 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -6,6 +6,7 @@ export { AuthBootstrap } from "./lib/components/AuthBootstrap"; export { useIdleTimer } from "./lib/hooks/useIdleTimer"; export { LoginPage } from "./lib/pages/LoginPage"; export { SignupPage } from "./lib/pages/SignupPage"; +export { FaydaCallbackPage } from "./lib/pages/FaydaCallbackPage"; export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage"; export { SetPasswordPage } from "./lib/pages/SetPasswordPage"; export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage"; diff --git a/libs/auth/src/lib/pages/FaydaCallbackPage.tsx b/libs/auth/src/lib/pages/FaydaCallbackPage.tsx new file mode 100644 index 000000000..d3f9ac1d1 --- /dev/null +++ b/libs/auth/src/lib/pages/FaydaCallbackPage.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react'; +import { Alert, Button, Group, Loader, Stack, Text, Title } from '@mantine/core'; +import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useApiMutation } from '@ema-platform/api'; +import { useErrorHandler } from '@ema-platform/ui'; +import { AuthShell } from '../components/AuthShell'; +import { faydaSession, type FaydaResult } from '../utils/fayda-session'; + +/** + * Where Fayda returns the applicant. + * + * It creates no account and holds no credentials — it hands the authorization + * code to the API, stashes the normalised result, and sends the applicant back + * to the signup form they started on. + */ +export function FaydaCallbackPage() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const [params] = useSearchParams(); + const { handleError } = useErrorHandler(); + const [error, setError] = useState(null); + const [callbackTrigger] = useApiMutation(); + + // React 18 mounts effects twice in development, and the authorization code is + // single-use — the second redemption would fail and show a spurious error. + const redeemed = useRef(false); + + useEffect(() => { + if (redeemed.current) return; + redeemed.current = true; + + const code = params.get('code'); + const state = params.get('state'); + const providerError = params.get('error'); + const request = faydaSession.takeRequest(); + + if (providerError) { + setError( + providerError === 'access_denied' + ? t('fayda.cancelled', 'Fayda verification was cancelled. You can still sign up manually.') + : t('fayda.rejected', 'Fayda could not verify your identity. Please try again.'), + ); + return; + } + + if (!code || !state) { + setError(t('fayda.invalidCallback', 'This verification link is incomplete. Please start again.')); + return; + } + + if (!request) { + setError( + t('fayda.sessionLost', 'Your verification session has expired. Please start again.'), + ); + return; + } + + if (request.state !== state) { + setError(t('fayda.stateMismatch', 'This verification could not be trusted. Please start again.')); + return; + } + + callbackTrigger({ + url: '/auth/fayda/callback', + method: 'POST', + body: { code, state, transactionToken: request.transactionToken }, + }) + .unwrap() + .then((result) => { + faydaSession.saveResult(result); + // replace: the callback URL carries a spent code, so it must not come + // back on Back. + navigate('/signup', { replace: true }); + }) + .catch((err: unknown) => setError(handleError(err))); + // Runs once on mount; the guard above makes that explicit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + {error ? ( + <> + + {t('fayda.failedTitle', 'Verification incomplete')} + + } + > + {error} + + + + + + ) : ( + + + {t('fayda.verifying', 'Verifying your Fayda identity…')} + + )} + + + ); +} diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index 13b7bb132..7045d9777 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Alert, Anchor, + Badge, Button, Checkbox, - Group, + Divider, PasswordInput, SimpleGrid, Stack, @@ -14,11 +15,14 @@ import { UnstyledButton, } from '@mantine/core'; import { + IconAlertTriangle, IconArrowLeft, IconArrowRight, IconAt, + IconId, IconLock, IconMail, + IconRosetteDiscountCheck, IconUser, } from '@tabler/icons-react'; import { useForm } from 'react-hook-form'; @@ -27,12 +31,13 @@ import { z } from 'zod'; import { useNavigate, Link } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import { useApiMutation } from '@ema-platform/api'; +import { useApiLazyQuery, useApiMutation } from '@ema-platform/api'; import { useErrorHandler, passwordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui'; import { AuthShell } from '../components/AuthShell'; import { loginSuccess, setUser } from '../store/auth.slice'; import type { AuthUser } from '../types/auth.types'; import { useAuthConfig } from '../AuthConfig'; +import { faydaSession, type FaydaResult } from '../utils/fayda-session'; interface SignupPayload { email: string; @@ -62,6 +67,53 @@ export function SignupPage() { }>(); const [meTrigger] = useApiMutation(); + // Fayda is optional: the form below works exactly as before without it. + const [fayda, setFayda] = useState(() => faydaSession.peekResult()); + const [faydaStarting, setFaydaStarting] = useState(false); + const [authorizeTrigger] = useApiLazyQuery<{ + authorizationUrl: string; + state: string; + transactionToken: string; + expiresIn: number; + }>(); + const [linkTrigger] = useApiMutation(); + + const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false; + const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false; + + /** + * Per-field provenance, so it is obvious which values came from Fayda and + * which are still the applicant's to supply. Verified fields stay editable — + * a conflicting email has to be changeable for the form to be completable at + * all. + */ + const faydaMark = (field: string): { description?: React.ReactNode } => { + if (conflicted(field)) { + return { + description: ( + + {t('fayda.fieldConflict', 'Already used by another account')} + + ), + }; + } + if (verified(field)) { + return { + description: ( + } + > + {t('fayda.fieldVerified', 'From Fayda')} + + ), + }; + } + return {}; + }; + const handleBack = () => { if (window.history.length > 1) { navigate(-1); @@ -118,6 +170,38 @@ export function SignupPage() { defaultValues: { userType: 'individual' }, }); + // Fills what Fayda vouched for and leaves the rest — username and password + // are always the applicant's to choose, and Fayda supplies neither. + useEffect(() => { + if (!fayda) return; + const { email, phoneNumber: phone, nameEn, nameAm } = fayda.prefill; + if (email) setValue('email', email); + if (phone) setValue('phoneNumber', phone); + if (nameEn) setValue('nameEn', nameEn); + if (nameAm) setValue('nameAm', nameAm); + }, [fayda, setValue]); + + const startFayda = async () => { + setServerError(null); + setFaydaStarting(true); + try { + const { authorizationUrl, transactionToken, state } = await authorizeTrigger({ + url: '/auth/fayda/authorize', + }).unwrap(); + + faydaSession.saveRequest({ transactionToken, state }); + window.location.assign(authorizationUrl); + } catch (err: unknown) { + setFaydaStarting(false); + setServerError(handleError(err)); + } + }; + + const clearFayda = () => { + faydaSession.clearResult(); + setFayda(null); + }; + const onSubmit = async (values: FormValues) => { try { const payload: SignupPayload = { @@ -147,6 +231,24 @@ export function SignupPage() { const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap(); dispatch(setUser(me)); + // Records the Fayda identity on the account that was just created. The + // registration endpoint is shared platform code and drops fields it does + // not know, so the link has to be a separate call. It is best-effort: the + // account is already usable, and the worst case is that it is not marked + // as Fayda-verified. + if (fayda) { + try { + await linkTrigger({ + url: '/auth/fayda/link', + method: 'POST', + body: { verificationToken: fayda.verificationToken }, + }).unwrap(); + } catch { + /* deliberately ignored — signup already succeeded */ + } + faydaSession.clearResult(); + } + if (data.isPhoneNumberVerified) { navigate(loginRedirectPath); } else { @@ -212,6 +314,53 @@ export function SignupPage() { )} + {fayda ? ( + } + title={t('fayda.verifiedTitle', 'Verified with Fayda')} + > + + + {t( + 'fayda.verifiedBody', + 'We filled in the details Fayda confirmed. Please complete the remaining fields.', + )} + + + {t('fayda.discard', 'Clear these details and fill the form myself')} + + + + ) : ( + <> + + + + )} + + {fayda && fayda.conflicts.length > 0 && ( + }> + {t( + 'fayda.conflictBody', + 'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.', + )} + + )} +
@@ -220,6 +369,7 @@ export function SignupPage() { placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')} leftSection={} error={errors.nameEn?.message} + {...faydaMark('nameEn')} {...register('nameEn')} /> } error={errors.nameAm?.message} + {...faydaMark('nameAm')} {...register('nameAm')} /> @@ -237,6 +388,7 @@ export function SignupPage() { placeholder={t('signup.emailPlaceholder', 'you@example.com')} leftSection={} error={errors.email?.message} + {...faydaMark('email')} {...register('email')} /> setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })} onBlur={() => trigger('phoneNumber')} error={errors.phoneNumber?.message} + {...faydaMark('phoneNumber')} /> diff --git a/libs/auth/src/lib/utils/fayda-session.ts b/libs/auth/src/lib/utils/fayda-session.ts new file mode 100644 index 000000000..2319e67fc --- /dev/null +++ b/libs/auth/src/lib/utils/fayda-session.ts @@ -0,0 +1,81 @@ +/** + * The Fayda round trip leaves the app entirely, so the little state that has to + * survive it lives in sessionStorage: same tab, same origin, gone when the tab + * closes. + * + * Nothing secret is kept here. `transactionToken` and `verificationToken` are + * signed by the API and are useless without it — the PKCE verifier and the + * client secret never leave the backend. + */ + +const REQUEST_KEY = 'fayda:request'; +const RESULT_KEY = 'fayda:result'; + +export interface FaydaRequest { + transactionToken: string; + state: string; +} + +export interface FaydaPrefill { + email?: string; + phoneNumber?: string; + nameEn?: string; + nameAm?: string; + /** Shown for context only — the signup form has no field for these. */ + gender?: string; + address?: string; +} + +export interface FaydaResult { + prefill: FaydaPrefill; + /** Always true when the API returned a result at all. */ + faydaVerified: boolean; + /** Signup fields Fayda vouched for. */ + verifiedFields: string[]; + /** Prefilled fields already taken by another account. */ + conflicts: string[]; + /** Posted to /auth/fayda/link once the account exists. */ + verificationToken: string; +} + +// Private browsing and locked-down browsers can throw on access, and a failure +// here should degrade to "no Fayda prefill", never break the signup page. +function read(key: string): T | null { + try { + const raw = sessionStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : null; + } catch { + return null; + } +} + +function write(key: string, value: unknown): void { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch { + /* nothing to do — the flow reports a generic failure instead */ + } +} + +function clear(key: string): void { + try { + sessionStorage.removeItem(key); + } catch { + /* ignore */ + } +} + +export const faydaSession = { + saveRequest: (request: FaydaRequest) => write(REQUEST_KEY, request), + takeRequest: (): FaydaRequest | null => { + const request = read(REQUEST_KEY); + // Single use: a stale token would otherwise be replayed against a fresh + // callback and fail with a confusing "session expired". + clear(REQUEST_KEY); + return request; + }, + + saveResult: (result: FaydaResult) => write(RESULT_KEY, result), + peekResult: (): FaydaResult | null => read(RESULT_KEY), + clearResult: () => clear(RESULT_KEY), +}; From c4c84abc660876a9425fd795574fbeb12633f57e Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 25 Aug 2026 13:01:40 +0000 Subject: [PATCH 2/7] refactor(signup): drive Fayda through register-with-fayda Follows the API back to a single endpoint. The page posts { action: 'start' } to open the attempt, the callback page posts { action: 'verify', ... } to get the verified identity, and the existing signup submission is unchanged. Drops the post-signup link call: there is no longer a link endpoint, and the account is created by the signup endpoint that already owns user creation. --- libs/auth/src/lib/pages/FaydaCallbackPage.tsx | 6 ++-- libs/auth/src/lib/pages/SignupPage.tsx | 33 ++++++------------- libs/auth/src/lib/utils/fayda-session.ts | 12 +++---- 3 files changed, 19 insertions(+), 32 deletions(-) diff --git a/libs/auth/src/lib/pages/FaydaCallbackPage.tsx b/libs/auth/src/lib/pages/FaydaCallbackPage.tsx index d3f9ac1d1..81314beb2 100644 --- a/libs/auth/src/lib/pages/FaydaCallbackPage.tsx +++ b/libs/auth/src/lib/pages/FaydaCallbackPage.tsx @@ -63,9 +63,11 @@ export function FaydaCallbackPage() { } callbackTrigger({ - url: '/auth/fayda/callback', + url: '/auth/register-with-fayda', method: 'POST', - body: { code, state, transactionToken: request.transactionToken }, + // `verify` returns the identity without creating an account — the + // existing signup endpoint still does that. + body: { action: 'verify', code, state, transactionToken: request.transactionToken }, }) .unwrap() .then((result) => { diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index 7045d9777..29b03e6c8 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -31,7 +31,7 @@ import { z } from 'zod'; import { useNavigate, Link } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import { useApiLazyQuery, useApiMutation } from '@ema-platform/api'; +import { useApiMutation } from '@ema-platform/api'; import { useErrorHandler, passwordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui'; import { AuthShell } from '../components/AuthShell'; import { loginSuccess, setUser } from '../store/auth.slice'; @@ -70,13 +70,12 @@ export function SignupPage() { // Fayda is optional: the form below works exactly as before without it. const [fayda, setFayda] = useState(() => faydaSession.peekResult()); const [faydaStarting, setFaydaStarting] = useState(false); - const [authorizeTrigger] = useApiLazyQuery<{ + const [startTrigger] = useApiMutation<{ authorizationUrl: string; state: string; transactionToken: string; expiresIn: number; }>(); - const [linkTrigger] = useApiMutation(); const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false; const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false; @@ -174,7 +173,7 @@ export function SignupPage() { // are always the applicant's to choose, and Fayda supplies neither. useEffect(() => { if (!fayda) return; - const { email, phoneNumber: phone, nameEn, nameAm } = fayda.prefill; + const { email, phoneNumber: phone, nameEn, nameAm } = fayda.identity; if (email) setValue('email', email); if (phone) setValue('phoneNumber', phone); if (nameEn) setValue('nameEn', nameEn); @@ -185,8 +184,12 @@ export function SignupPage() { setServerError(null); setFaydaStarting(true); try { - const { authorizationUrl, transactionToken, state } = await authorizeTrigger({ - url: '/auth/fayda/authorize', + // Same endpoint the registration itself uses; `start` only opens the + // attempt and hands back where to send the user. + const { authorizationUrl, transactionToken, state } = await startTrigger({ + url: '/auth/register-with-fayda', + method: 'POST', + body: { action: 'start' }, }).unwrap(); faydaSession.saveRequest({ transactionToken, state }); @@ -231,23 +234,7 @@ export function SignupPage() { const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap(); dispatch(setUser(me)); - // Records the Fayda identity on the account that was just created. The - // registration endpoint is shared platform code and drops fields it does - // not know, so the link has to be a separate call. It is best-effort: the - // account is already usable, and the worst case is that it is not marked - // as Fayda-verified. - if (fayda) { - try { - await linkTrigger({ - url: '/auth/fayda/link', - method: 'POST', - body: { verificationToken: fayda.verificationToken }, - }).unwrap(); - } catch { - /* deliberately ignored — signup already succeeded */ - } - faydaSession.clearResult(); - } + faydaSession.clearResult(); if (data.isPhoneNumberVerified) { navigate(loginRedirectPath); diff --git a/libs/auth/src/lib/utils/fayda-session.ts b/libs/auth/src/lib/utils/fayda-session.ts index 2319e67fc..d61db63c1 100644 --- a/libs/auth/src/lib/utils/fayda-session.ts +++ b/libs/auth/src/lib/utils/fayda-session.ts @@ -3,9 +3,9 @@ * survive it lives in sessionStorage: same tab, same origin, gone when the tab * closes. * - * Nothing secret is kept here. `transactionToken` and `verificationToken` are - * signed by the API and are useless without it — the PKCE verifier and the - * client secret never leave the backend. + * Nothing secret is kept here. The `transactionToken` is signed by the API and + * useless without it — the PKCE verifier, the nonce and the client key never + * leave the backend. */ const REQUEST_KEY = 'fayda:request'; @@ -26,16 +26,14 @@ export interface FaydaPrefill { address?: string; } +/** Shape of `POST /auth/register-with-fayda` with `action: "verify"`. */ export interface FaydaResult { - prefill: FaydaPrefill; - /** Always true when the API returned a result at all. */ + identity: FaydaPrefill; faydaVerified: boolean; /** Signup fields Fayda vouched for. */ verifiedFields: string[]; /** Prefilled fields already taken by another account. */ conflicts: string[]; - /** Posted to /auth/fayda/link once the account exists. */ - verificationToken: string; } // Private browsing and locked-down browsers can throw on access, and a failure From 0582c311c9845708055bb19f12579b7db585eda8 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 25 Aug 2026 13:24:29 +0000 Subject: [PATCH 3/7] feat(signup): serve the Fayda callback at /callback too Fayda matches the registered redirect URI exactly, and the value being registered first is a bare /callback. The descriptive path stays so the route still reads as part of signup once that can be changed. --- apps/portal/src/app/router.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 348b98685..5e2c539c5 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -69,9 +69,14 @@ export const router = createBrowserRouter([ { path: "/signup", element: }, // Where Fayda returns the applicant. Public by necessity — they have no - // account yet. It redeems the code and hands control back to /signup; the - // path must match the API's REDIRECT_URI, which is registered with Fayda. + // account yet. It redeems the code and hands control back to /signup. + // + // Two paths for one page: whichever is registered with Fayda has to match the + // API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a + // bare /callback. The descriptive path is kept so the route still reads as + // part of signup once that can be changed. { path: "/signup/fayda/callback", element: }, + { path: "/callback", element: }, // Completes the forgot-password flow; the reset message links here. The // IAM package generates `/reset-password` links, `/set-password` is the From 16a5dec734375d3c5a246adf4f551afb1ab7c0c0 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 25 Aug 2026 18:18:02 +0000 Subject: [PATCH 4/7] docs(env): add the .env.example the README already told people to copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md line 72 says `cp .env.example .env`, but no such file existed and .gitignore's `.env.*` rule would have swallowed it — hence the explicit negation alongside it. Documents the two variables the code actually reads, VITE_BASE_API_URL and VITE_USE_MOCKS, rather than the longer list in the README, none of which is referenced anywhere in apps/ or libs/. Notes that Vite resolves this from the workspace root via envDir, and that Fayda needs nothing here beyond the callback path matching the API's FAYDA_REDIRECT_URI. --- .env.example | 38 ++++++++++++++++++++++++++++++++++++++ .gitignore | 2 ++ 2 files changed, 40 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..abf8c4034 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# emaui environment +# +# Vite reads this from the workspace root, not from the app directory: +# apps/portal/vite.config.mts and apps/backoffice/vite.config.mts both set +# `envDir: '../../'`. So copy this file to ./.env here, at the repo root. +# +# cp .env.example .env +# +# Only VITE_-prefixed values reach the browser, and everything that does is +# public — it ships inside the built bundle. Never put a secret here. The Fayda +# client id, private key and endpoints live in the API's environment only; the +# frontend never speaks to Fayda directly. + +# Base URL of the emaapi backend, including the /api prefix. +# Defaults to http://localhost:3000/api when unset. +VITE_BASE_API_URL=http://localhost:3000/api + +# Serve fixture data instead of calling the API. Any value other than "true" +# uses the real backend. +VITE_USE_MOCKS=false + +# --- Docker Compose only ----------------------------------------------------- +# Host ports published by docker-compose.yml. It also expects per-app env files +# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this +# file. Ignored when running the Vite dev servers, which serve the portal on +# 4200 and the backoffice on 4201. +# EMA_PORTAL_PORT=8021 +# EMA_BACKOFFICE_PORT=8022 + +# --- Fayda note -------------------------------------------------------------- +# There is nothing to configure here for Fayda. The portal serves the callback +# page at /callback and /signup/fayda/callback, and whichever path is registered +# with Fayda must match the API's FAYDA_REDIRECT_URI exactly. +# +# The value being registered first is http://localhost:3000/callback, which is +# also emaapi's default port — so when testing that redirect, serve the portal +# on 3000 (`npx nx serve portal --port 3000`) and move the API elsewhere, or +# register a URI that points at the port the portal actually runs on. diff --git a/.gitignore b/.gitignore index 4b15bf545..8b70b3e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ coverage/ # env .env .env.* +# ...but the checked-in template must survive that rule. +!.env.example # logs *.log From baea9f2b577c208bd42680027748637f9f00ba09 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 25 Aug 2026 19:04:27 +0000 Subject: [PATCH 5/7] chore(dev): serve the portal on 3000 for the Fayda redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fayda redirect URI registered for local testing is http://localhost:3000/callback, matched exactly by the provider, so the portal has to be the thing listening there. Its dev and preview servers move from 4200 to 3000 and the API moves to 3001. Every hardcoded fallback to http://localhost:3000/api follows — six copies of the same default across libs/api, libs/auth, the portal and the backoffice — otherwise a developer without a .env would have had the app calling itself. e2e is unaffected: it binds its own ports (3011/4302/4303) explicitly. --- .env.example | 15 ++++++++------- .../certificate-designer/config/designer.ts | 2 +- apps/backoffice/vite.config.mts | 2 +- apps/e2e/README.md | 2 +- .../certificates/pages/CertificatesPage.tsx | 2 +- apps/portal/vite.config.mts | 9 ++++++--- .../src/lib/base-api/base-query-with-reauth.ts | 2 +- .../lib/features/licensing/licensing.helpers.ts | 2 +- libs/api/src/lib/session/index.ts | 2 +- libs/auth/src/lib/components/AuthBootstrap.tsx | 2 +- libs/auth/src/lib/utils/refresh-token.ts | 2 +- 11 files changed, 23 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index abf8c4034..05b61e286 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,10 @@ # frontend never speaks to Fayda directly. # Base URL of the emaapi backend, including the /api prefix. -# Defaults to http://localhost:3000/api when unset. -VITE_BASE_API_URL=http://localhost:3000/api +# 3001, not 3000: the portal itself takes 3000 in development, because that is +# the port in the Fayda redirect URI registered for local testing. Set PORT=3001 +# in emaapi's .env to match. +VITE_BASE_API_URL=http://localhost:3001/api # Serve fixture data instead of calling the API. Any value other than "true" # uses the real backend. @@ -23,7 +25,7 @@ VITE_USE_MOCKS=false # Host ports published by docker-compose.yml. It also expects per-app env files # at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this # file. Ignored when running the Vite dev servers, which serve the portal on -# 4200 and the backoffice on 4201. +# 3000 and the backoffice on 4201. # EMA_PORTAL_PORT=8021 # EMA_BACKOFFICE_PORT=8022 @@ -32,7 +34,6 @@ VITE_USE_MOCKS=false # page at /callback and /signup/fayda/callback, and whichever path is registered # with Fayda must match the API's FAYDA_REDIRECT_URI exactly. # -# The value being registered first is http://localhost:3000/callback, which is -# also emaapi's default port — so when testing that redirect, serve the portal -# on 3000 (`npx nx serve portal --port 3000`) and move the API elsewhere, or -# register a URI that points at the port the portal actually runs on. +# The value being registered first is http://localhost:3000/callback, so the +# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing +# extra to run — `nx serve portal` already binds the right port. diff --git a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts index f6fd5abae..07d22cd74 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts @@ -3,7 +3,7 @@ import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ /** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */ export const API_BASE_URL = (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3000/api'; + 'http://localhost:3001/api'; export const STATUS_COLOR: Record = { DRAFT: 'gray', diff --git a/apps/backoffice/vite.config.mts b/apps/backoffice/vite.config.mts index 97ed5cae3..be6f4eef9 100644 --- a/apps/backoffice/vite.config.mts +++ b/apps/backoffice/vite.config.mts @@ -17,7 +17,7 @@ export default defineConfig({ // port: 4201, // proxy: { // '/api': { -// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com' +// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com' // changeOrigin: true, // }, // }, diff --git a/apps/e2e/README.md b/apps/e2e/README.md index cfec73a58..86c75a6da 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -22,7 +22,7 @@ own ports, against its own database: | Backoffice | 4303 | same | | Database | — | `ema_e2e` | -This is deliberate. A developer's stack is usually already up on 3000/4200/4201, +This is deliberate. A developer's stack is usually already up on 3000/3001/4201, and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps' `.env.local` on every run — a suite that read those files would point at whichever stack was started last. The API is launched with `DATABASE_NAME`, diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index c15fa983b..edbe6f516 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -122,7 +122,7 @@ function formatDate(value: string | null | undefined): string { const API_BASE = (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3000/api'; + 'http://localhost:3001/api'; async function generateCertificate(profileId: string): Promise { const token = authStorage.getToken(); diff --git a/apps/portal/vite.config.mts b/apps/portal/vite.config.mts index 4e08b10fe..6487e7752 100644 --- a/apps/portal/vite.config.mts +++ b/apps/portal/vite.config.mts @@ -9,17 +9,20 @@ export default defineConfig({ // built-in default. envDir: '../../', cacheDir: '../../node_modules/.vite/apps/portal', - server: { port: 4200, host: 'localhost' }, + // 3000, not the usual 4200: the Fayda redirect URI registered for local + // testing is http://localhost:3000/callback, and the provider matches it + // exactly. The API moves to 3001 to make room. + server: { port: 3000, host: 'localhost' }, // server: { // port: 4200, // proxy: { // '/api': { -// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com' +// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com' // changeOrigin: true, // }, // }, // }, - preview: { port: 4200, host: 'localhost' }, + preview: { port: 3000, host: 'localhost' }, plugins: [react(), nxViteTsPaths()], resolve: { dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'], diff --git a/libs/api/src/lib/base-api/base-query-with-reauth.ts b/libs/api/src/lib/base-api/base-query-with-reauth.ts index c47336cd8..6f27c1b4f 100644 --- a/libs/api/src/lib/base-api/base-query-with-reauth.ts +++ b/libs/api/src/lib/base-api/base-query-with-reauth.ts @@ -5,7 +5,7 @@ import { resolveSessionContext } from "../session"; export const BASE_API_URL = (import.meta as { env?: Record }).env?.[ "VITE_BASE_API_URL" - ] ?? "http://localhost:3000/api"; + ] ?? "http://localhost:3001/api"; let _onTokenExpired: (() => Promise) | null = null; let _onAuthFailure: (() => void) | null = null; diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index ecb0218d4..c74eabbc6 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -12,7 +12,7 @@ import type { const BASE_API_URL = (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3000/api'; + 'http://localhost:3001/api'; /** * Uploads a document straight to the API. diff --git a/libs/api/src/lib/session/index.ts b/libs/api/src/lib/session/index.ts index 69277fd91..f907697ab 100644 --- a/libs/api/src/lib/session/index.ts +++ b/libs/api/src/lib/session/index.ts @@ -11,7 +11,7 @@ export const SESSION_HEADER_KEYS = { * Which app this bundle is, so it reads its own session and no one else's. * * Set by each app's store via `configureSessionScope`. Cookies ignore the - * port, so `localhost:4200` and `localhost:4201` share one jar: without a + * port, so `localhost:3000` and `localhost:4201` share one jar: without a * scope the backoffice would happily authenticate as whoever last signed into * the portal, and render a staff console with an applicant's permissions. */ diff --git a/libs/auth/src/lib/components/AuthBootstrap.tsx b/libs/auth/src/lib/components/AuthBootstrap.tsx index 251db18c0..afabc2988 100644 --- a/libs/auth/src/lib/components/AuthBootstrap.tsx +++ b/libs/auth/src/lib/components/AuthBootstrap.tsx @@ -8,7 +8,7 @@ import type { AuthUser } from '../types/auth.types'; const BASE_API_URL = (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3000/api'; + 'http://localhost:3001/api'; /** * Restores the signed-in session before the router renders. diff --git a/libs/auth/src/lib/utils/refresh-token.ts b/libs/auth/src/lib/utils/refresh-token.ts index 4b6997f8a..1d5312593 100644 --- a/libs/auth/src/lib/utils/refresh-token.ts +++ b/libs/auth/src/lib/utils/refresh-token.ts @@ -3,7 +3,7 @@ import { authStorage } from "./auth-storage"; const BASE_API_URL = (import.meta as { env?: Record }).env?.[ "VITE_BASE_API_URL" - ] ?? "http://localhost:3000/api"; + ] ?? "http://localhost:3001/api"; interface RefreshResponse { token: string; From c753009ed99bd40c37feaa5432d9a1fe67c8dcc8 Mon Sep 17 00:00:00 2001 From: mihretue Date: Wed, 26 Aug 2026 09:09:25 +0000 Subject: [PATCH 6/7] refactor(api): resolve the backend URL in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VITE_BASE_API_URL with a localhost:3001/api fallback was re-derived in six files across libs/api, libs/auth, the portal and the backoffice — and a seventh site (UserManagementPage) read the env raw with no fallback at all, handing the IAM user-management app an undefined apiUrl whenever no .env was present. BASE_API_URL in base-query-with-reauth.ts is now the single definition, exported from @ema-platform/api; every other site imports it. Trims the env value and treats blank as unset, matching how the backend reads its own keys. --- .../app/features/certificate-designer/config/designer.ts | 6 +++--- .../app/features/user-management/UserManagementPage.tsx | 3 ++- .../app/features/certificates/pages/CertificatesPage.tsx | 4 +--- libs/api/src/index.ts | 2 +- libs/api/src/lib/base-api/base-query-with-reauth.ts | 7 ++++++- libs/api/src/lib/features/licensing/licensing.helpers.ts | 4 +--- libs/auth/src/lib/components/AuthBootstrap.tsx | 4 +--- libs/auth/src/lib/utils/refresh-token.ts | 5 +---- 8 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts index 07d22cd74..056d2a739 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts @@ -1,9 +1,9 @@ import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api'; +import { BASE_API_URL } from '@ema-platform/api'; + /** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */ -export const API_BASE_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; +export const API_BASE_URL = BASE_API_URL; export const STATUS_COLOR: Record = { DRAFT: 'gray', diff --git a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx index 6a96bba84..20d277399 100644 --- a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx +++ b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useNavigate } from 'react-router-dom'; import { UserManagementApp } from '@tria-plc/iamui'; +import { BASE_API_URL } from '@ema-platform/api'; import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui'; import '@tria-plc/iamui/style.css'; @@ -99,7 +100,7 @@ const UM_CONFIG: DesignConfig = { const UM_RUNTIME = { basename: '/um', - apiUrl: import.meta.env.VITE_BASE_API_URL, + apiUrl: BASE_API_URL, }; const buttonStyle: React.CSSProperties = { diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index edbe6f516..f7cf52743 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string { }); } -const API_BASE = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; +import { BASE_API_URL as API_BASE } from '@ema-platform/api'; async function generateCertificate(profileId: string): Promise { const token = authStorage.getToken(); diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts index 903012532..e01bec7dd 100644 --- a/libs/api/src/index.ts +++ b/libs/api/src/index.ts @@ -7,5 +7,5 @@ export * from './lib/features/seafarer'; export * from './lib/features/seafarer-registration'; export * from './lib/features/seafarer-document'; export * from './lib/features/vessel'; -export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; +export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download'; diff --git a/libs/api/src/lib/base-api/base-query-with-reauth.ts b/libs/api/src/lib/base-api/base-query-with-reauth.ts index 6f27c1b4f..8b9feb2cb 100644 --- a/libs/api/src/lib/base-api/base-query-with-reauth.ts +++ b/libs/api/src/lib/base-api/base-query-with-reauth.ts @@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react"; import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query"; import { resolveSessionContext } from "../session"; +/** + * The one place the backend URL is resolved: VITE_BASE_API_URL from the env, + * falling back to the local dev API (3001 — the portal itself owns 3000 for + * the Fayda redirect). Import this; do not re-derive it. + */ export const BASE_API_URL = (import.meta as { env?: Record }).env?.[ "VITE_BASE_API_URL" - ] ?? "http://localhost:3001/api"; + ]?.trim() || "http://localhost:3001/api"; let _onTokenExpired: (() => Promise) | null = null; let _onAuthFailure: (() => void) | null = null; diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index c74eabbc6..e5833387d 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -10,9 +10,7 @@ import type { ValidationIssue, } from './licensing.types'; -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; +import { BASE_API_URL } from '../../base-api/base-query-with-reauth'; /** * Uploads a document straight to the API. diff --git a/libs/auth/src/lib/components/AuthBootstrap.tsx b/libs/auth/src/lib/components/AuthBootstrap.tsx index afabc2988..264738c00 100644 --- a/libs/auth/src/lib/components/AuthBootstrap.tsx +++ b/libs/auth/src/lib/components/AuthBootstrap.tsx @@ -6,9 +6,7 @@ import { hydrateAuth, logout, setToken, setUser } from '../store/auth.slice'; import { refreshAccessToken } from '../utils/refresh-token'; import type { AuthUser } from '../types/auth.types'; -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; +import { BASE_API_URL } from '@ema-platform/api'; /** * Restores the signed-in session before the router renders. diff --git a/libs/auth/src/lib/utils/refresh-token.ts b/libs/auth/src/lib/utils/refresh-token.ts index 1d5312593..6ceb275cf 100644 --- a/libs/auth/src/lib/utils/refresh-token.ts +++ b/libs/auth/src/lib/utils/refresh-token.ts @@ -1,9 +1,6 @@ import { authStorage } from "./auth-storage"; -const BASE_API_URL = - (import.meta as { env?: Record }).env?.[ - "VITE_BASE_API_URL" - ] ?? "http://localhost:3001/api"; +import { BASE_API_URL } from "@ema-platform/api"; interface RefreshResponse { token: string; From 8992d924940999e399d735188a638a61476a1d01 Mon Sep 17 00:00:00 2001 From: mihretue Date: Thu, 27 Aug 2026 13:42:18 +0000 Subject: [PATCH 7/7] feat(signup): present the Fayda proof after signup and skip the second OTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify step now returns an encrypted verificationToken alongside the readable identity. After the existing signup succeeds, the page posts it to POST /profiles/me/fayda, which marks the phone verified when it is the one Fayda vouched for and fills the still-empty profile fields. Best-effort: the account already works if the call fails, and the token can be presented again. When the server confirms the phone, the page goes straight to the portal instead of /otp-verify — Fayda already verified that exact number with its own OTP, and asking for a second one on the same number is theatre. An applicant who edited the prefilled number still gets the normal OTP step. --- libs/auth/src/lib/pages/SignupPage.tsx | 27 ++++++++++++++++++++++-- libs/auth/src/lib/utils/fayda-session.ts | 8 +++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/libs/auth/src/lib/pages/SignupPage.tsx b/libs/auth/src/lib/pages/SignupPage.tsx index 29b03e6c8..3b41c45de 100644 --- a/libs/auth/src/lib/pages/SignupPage.tsx +++ b/libs/auth/src/lib/pages/SignupPage.tsx @@ -76,6 +76,7 @@ export function SignupPage() { transactionToken: string; expiresIn: number; }>(); + const [linkTrigger] = useApiMutation<{ phoneNumberVerified?: boolean }>(); const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false; const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false; @@ -89,8 +90,10 @@ export function SignupPage() { const faydaMark = (field: string): { description?: React.ReactNode } => { if (conflicted(field)) { return { + // component="span" on these badges: the description slot renders + // inside a

, where Badge's default

is invalid HTML. description: ( - + {t('fayda.fieldConflict', 'Already used by another account')} ), @@ -100,6 +103,7 @@ export function SignupPage() { return { description: (