Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-28 15:27:43 +03:00
35 changed files with 931 additions and 204 deletions

View File

@@ -9,5 +9,5 @@ export * from './lib/features/seafarer-document';
export * from './lib/features/personal-document';
export * from './lib/features/biometric-enrollment';
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';

View File

@@ -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<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
]?.trim() || "http://localhost:3001/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -11,9 +11,7 @@ import type {
ValidationIssue,
} from './licensing.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
/**
* Uploads a document straight to the API.

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import Cookies from "js-cookie";
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
currentPositionId: 'x-current-position-id',
currentProjectId: 'x-current-project-id',
tenantId: "x-tenant-id",
organizationUnitId: "x-organization-unit-id",
currentPositionId: "x-current-position-id",
currentProjectId: "x-current-project-id",
} as const;
/**
* 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:3001` 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.
*/
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
}
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
const LEGACY_TOKEN_KEY = 'auth-token';
const LEGACY_TOKEN_KEY = "auth-token";
export function resolveTokenFromStorage(): string | undefined {
// Only this app's key, then the legacy unprefixed one. Never another app's:

View File

@@ -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";

View File

@@ -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<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL } from '@ema-platform/api';
/**
* Restores the signed-in session before the router renders.

View File

@@ -0,0 +1,121 @@
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/register-with-fayda',
method: 'POST',
// `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) => {
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>
);
}

View File

@@ -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';
@@ -33,6 +37,7 @@ 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,56 @@ 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 [startTrigger] = useApiMutation<{
authorizationUrl: string;
state: string;
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;
/**
* 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 {
// component="span" on these badges: the description slot renders
// inside a <p>, where Badge's default <div> is invalid HTML.
description: (
<Badge component="span" size="xs" variant="light" color="orange">
{t('fayda.fieldConflict', 'Already used by another account')}
</Badge>
),
};
}
if (verified(field)) {
return {
description: (
<Badge
component="span"
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 +173,42 @@ 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.identity;
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 {
// 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 });
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,7 +238,28 @@ export function SignupPage() {
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
// Records the Fayda-verified identity on the new account: 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,
// and the token can be presented again on a retry.
let faydaPhoneVerified = false;
if (fayda?.verificationToken) {
try {
const applied = await linkTrigger({
url: '/profiles/me/fayda',
method: 'POST',
body: { verificationToken: fayda.verificationToken },
}).unwrap();
faydaPhoneVerified = Boolean(applied?.phoneNumberVerified);
} catch {
/* deliberately ignored — signup already succeeded */
}
}
faydaSession.clearResult();
// Fayda already verified this exact number via its own OTP; asking for
// a second OTP on the same number is theatre.
if (data.isPhoneNumberVerified || faydaPhoneVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
@@ -212,6 +324,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 +379,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 +387,7 @@ export function SignupPage() {
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...faydaMark('nameAm')}
{...register('nameAm')}
/>
</SimpleGrid>
@@ -237,6 +398,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 +417,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">

View File

@@ -0,0 +1,87 @@
/**
* 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. 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';
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;
birthdate?: string;
nationality?: string;
faydaNumber?: string;
}
/** Shape of `POST /auth/register-with-fayda` with `action: "verify"`. */
export interface FaydaResult {
identity: FaydaPrefill;
faydaVerified: boolean;
/** Signup fields Fayda vouched for. */
verifiedFields: string[];
/** Prefilled fields already taken by another account. */
conflicts: string[];
/**
* Encrypted proof of the verification, presented to POST /profiles/me/fayda
* after signup so the account and profile record what Fayda vouched for.
*/
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),
};

View File

@@ -1,9 +1,6 @@
import { authStorage } from "./auth-storage";
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
import { BASE_API_URL } from "@ema-platform/api";
interface RefreshResponse {
token: string;