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), +};