mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 02:58:12 +00:00
feat(signup): verify identity with Fayda and prefill the form
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.
This commit is contained in:
@@ -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";
|
||||
|
||||
119
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal file
119
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [callbackTrigger] = useApiMutation<FaydaResult>();
|
||||
|
||||
// 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 (
|
||||
<AuthShell
|
||||
brandTitle={t('fayda.brandTitle', 'Verifying with Fayda')}
|
||||
brandSubtitle={t('fayda.brandSubtitle', 'One moment while we confirm your identity.')}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{error ? (
|
||||
<>
|
||||
<Title order={2} fz={26}>
|
||||
{t('fayda.failedTitle', 'Verification incomplete')}
|
||||
</Title>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconArrowLeft size={18} />}
|
||||
onClick={() => navigate('/signup', { replace: true })}
|
||||
>
|
||||
{t('fayda.backToSignup', 'Back to sign up')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Group gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed">{t('fayda.verifying', 'Verifying your Fayda identity…')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -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<AuthUser>();
|
||||
|
||||
// Fayda is optional: the form below works exactly as before without it.
|
||||
const [fayda, setFayda] = useState<FaydaResult | null>(() => faydaSession.peekResult());
|
||||
const [faydaStarting, setFaydaStarting] = useState(false);
|
||||
const [authorizeTrigger] = useApiLazyQuery<{
|
||||
authorizationUrl: string;
|
||||
state: string;
|
||||
transactionToken: string;
|
||||
expiresIn: number;
|
||||
}>();
|
||||
const [linkTrigger] = useApiMutation<void>();
|
||||
|
||||
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: (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{t('fayda.fieldConflict', 'Already used by another account')}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (verified(field)) {
|
||||
return {
|
||||
description: (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={11} />}
|
||||
>
|
||||
{t('fayda.fieldVerified', 'From Fayda')}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
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() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fayda ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconRosetteDiscountCheck size={18} />}
|
||||
title={t('fayda.verifiedTitle', 'Verified with Fayda')}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'fayda.verifiedBody',
|
||||
'We filled in the details Fayda confirmed. Please complete the remaining fields.',
|
||||
)}
|
||||
</Text>
|
||||
<Anchor size="sm" component="button" type="button" onClick={clearFayda}>
|
||||
{t('fayda.discard', 'Clear these details and fill the form myself')}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="md"
|
||||
fullWidth
|
||||
loading={faydaStarting}
|
||||
leftSection={<IconId size={18} />}
|
||||
onClick={startFayda}
|
||||
>
|
||||
{t('fayda.continueWith', 'Continue with Fayda')}
|
||||
</Button>
|
||||
<Divider
|
||||
label={t('fayda.orFillManually', 'or fill in your details')}
|
||||
labelPosition="center"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{fayda && fayda.conflicts.length > 0 && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={18} />}>
|
||||
{t(
|
||||
'fayda.conflictBody',
|
||||
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
@@ -220,6 +369,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameEn?.message}
|
||||
{...faydaMark('nameEn')}
|
||||
{...register('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -227,6 +377,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...faydaMark('nameAm')}
|
||||
{...register('nameAm')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
@@ -237,6 +388,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...faydaMark('email')}
|
||||
{...register('email')}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -255,6 +407,7 @@ export function SignupPage() {
|
||||
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
|
||||
onBlur={() => trigger('phoneNumber')}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...faydaMark('phoneNumber')}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
|
||||
81
libs/auth/src/lib/utils/fayda-session.ts
Normal file
81
libs/auth/src/lib/utils/fayda-session.ts
Normal file
@@ -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<T>(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<FaydaRequest>(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<FaydaResult>(RESULT_KEY),
|
||||
clearResult: () => clear(RESULT_KEY),
|
||||
};
|
||||
Reference in New Issue
Block a user