From 933ba41476635ee8e8adb04ae5722dde97cd9a08 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 25 Aug 2026 12:30:57 +0000 Subject: [PATCH] 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), +};