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}
+
+
+ }
+ onClick={() => navigate('/signup', { replace: true })}
+ >
+ {t('fayda.backToSignup', 'Back to sign up')}
+
+
+ >
+ ) : (
+
+
+ {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')}
+
+
+
+ ) : (
+ <>
+ }
+ onClick={startFayda}
+ >
+ {t('fayda.continueWith', 'Continue with Fayda')}
+
+
+ >
+ )}
+
+ {fayda && fayda.conflicts.length > 0 && (
+ }>
+ {t(
+ 'fayda.conflictBody',
+ 'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
+ )}
+
+ )}
+