Merge pull request #38 from Tria-plc/feature/fayda-auth-and-email-notifications

Feature/fayda auth and email notifications
This commit is contained in:
Mihretu Endeshaw
2026-08-28 14:43:13 +03:00
committed by GitHub
22 changed files with 620 additions and 136 deletions

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
# emaui environment
#
# Vite reads this from the workspace root, not from the app directory:
# apps/portal/vite.config.mts and apps/backoffice/vite.config.mts both set
# `envDir: '../../'`. So copy this file to ./.env here, at the repo root.
#
# cp .env.example .env
#
# Only VITE_-prefixed values reach the browser, and everything that does is
# public — it ships inside the built bundle. Never put a secret here. The Fayda
# client id, private key and endpoints live in the API's environment only; the
# frontend never speaks to Fayda directly.
# Base URL of the emaapi backend, including the /api prefix.
# 3001, not 3000: the portal itself takes 3000 in development, because that is
# the port in the Fayda redirect URI registered for local testing. Set PORT=3001
# in emaapi's .env to match.
VITE_BASE_API_URL=http://localhost:3001/api
# Serve fixture data instead of calling the API. Any value other than "true"
# uses the real backend.
VITE_USE_MOCKS=false
# --- Docker Compose only -----------------------------------------------------
# Host ports published by docker-compose.yml. It also expects per-app env files
# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this
# file. Ignored when running the Vite dev servers, which serve the portal on
# 3000 and the backoffice on 4201.
# EMA_PORTAL_PORT=8021
# EMA_BACKOFFICE_PORT=8022
# --- Fayda note --------------------------------------------------------------
# There is nothing to configure here for Fayda. The portal serves the callback
# page at /callback and /signup/fayda/callback, and whichever path is registered
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
#
# The value being registered first is http://localhost:3001/callback, so the
# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing
# extra to run — `nx serve portal` already binds the right port.

2
.gitignore vendored
View File

@@ -13,6 +13,8 @@ coverage/
# env
.env
.env.*
# ...but the checked-in template must survive that rule.
!.env.example
# logs
*.log

View File

@@ -6,20 +6,20 @@ A fully scaffolded Nx monorepo housing two Vite + React 19 SPAs (Backoffice and
## Tech Stack
| Tool | Version |
|------|---------|
| React | 19 |
| Nx | 22 |
| Vite | 7 |
| TypeScript | 5.9 |
| Redux Toolkit | 2.11 |
| Mantine | 8.3 |
| React Router | 7 |
| TanStack Query | 5 |
| React Hook Form | 7 |
| Zod | 4 |
| Tailwind CSS | 3.4 |
| Vitest | 4 |
| Tool | Version |
| --------------- | ------- |
| React | 19 |
| Nx | 22 |
| Vite | 7 |
| TypeScript | 5.9 |
| Redux Toolkit | 2.11 |
| Mantine | 8.3 |
| React Router | 7 |
| TanStack Query | 5 |
| React Hook Form | 7 |
| Zod | 4 |
| Tailwind CSS | 3.4 |
| Vitest | 4 |
---
@@ -37,16 +37,19 @@ emaui/
```
### libs/api
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
- `session/``resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback.
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
### libs/ui
- `ConfirmModal` — Reusable Mantine modal for destructive-action confirmation.
- `ApiErrorAlert` — Extracts a human-readable message from RTK Query error shapes or Error objects.
- `notify` — Thin wrapper around `@mantine/notifications` with `.success`, `.error`, `.info`, `.warning` helpers.
### libs/shared
- `ema-theme` — Mantine v8 `createTheme()` with `emaPrimary` (blue) and `emaSecondary` (warm) color tuples, Inter font, and custom shadow scale.
---
@@ -86,14 +89,14 @@ npm run dev:all
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
| Variable | Required | Default | Description |
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_BASE_API_URL` | Yes | `http://localhost:3001` | Base URL for all API requests |
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
---
@@ -112,6 +115,7 @@ The `Dockerfile` uses multi-stage builds with named targets (`portal` / `backoff
## Adding a New Feature
1. Create the feature folder under the relevant app:
```
apps/backoffice/src/app/features/<feature-name>/
├── types/ # TypeScript interfaces

View File

@@ -1,9 +1,9 @@
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
import { BASE_API_URL } from '@ema-platform/api';
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
export const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
export const API_BASE_URL = BASE_API_URL;
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',

View File

@@ -1,10 +1,14 @@
import Cookies from 'js-cookie';
import { useCallback, useEffect, useRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useNavigate } from 'react-router-dom';
import { UserManagementApp } from '@tria-plc/iamui';
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
import '@tria-plc/iamui/style.css';
import Cookies from "js-cookie";
import { useCallback, useEffect, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { useNavigate } from "react-router-dom";
import { UserManagementApp } from "@tria-plc/iamui";
import { BASE_API_URL } from "@ema-platform/api";
import type {
DesignConfig,
UserManagementSessionOptions,
} from "@tria-plc/iamui";
import "@tria-plc/iamui/style.css";
const UM_OVERRIDES = `
.um-theme-light {
@@ -58,73 +62,73 @@ const UM_OVERRIDES = `
const UM_CONFIG: DesignConfig = {
brand: {
appName: 'Ethiopian Maritime Licence',
logoUrl: '/assets/emaLogo.jpg',
appName: "Ethiopian Maritime Licence",
logoUrl: "/assets/emaLogo.jpg",
},
colors: {
primary: '#2563eb',
sidebar: '#ffffff',
background: '#f8fafc',
foreground: '#1e293b',
border: '#e2e8f0',
mutedForeground: '#94a3b8',
card: '#ffffff',
primary: "#2563eb",
sidebar: "#ffffff",
background: "#f8fafc",
foreground: "#1e293b",
border: "#e2e8f0",
mutedForeground: "#94a3b8",
card: "#ffffff",
},
typography: {
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
},
layout: {
userManagementView: 'classic',
sidebarBrandLabel: 'Ethiopian Maritime Authority',
sidebarBrandSublabel: 'User Management',
sidebarBackground: '#ffffff',
sidebarColor: '#1e293b',
sidebarMutedColor: '#94a3b8',
sidebarActiveBackground: '#eff6ff',
sidebarActiveColor: '#2563eb',
sidebarHoverBackground: '#f8fafc',
sidebarBorder: '#e2e8f0',
sidebarWidth: '280px',
sidebarCollapsedWidth: '80px',
modalAccentColor: '#2563eb',
modalHeaderBackground: '#f8fafc',
modalHeaderEditBackground: '#eff6ff',
modalIconBackground: '#eff6ff',
modalIconColor: '#2563eb',
modalTitleColor: '#1e293b',
modalFocusColor: '#2563eb',
modalSurface: '#ffffff',
userManagementView: "classic",
sidebarBrandLabel: "Ethiopian Maritime Authority",
sidebarBrandSublabel: "User Management",
sidebarBackground: "#ffffff",
sidebarColor: "#1e293b",
sidebarMutedColor: "#94a3b8",
sidebarActiveBackground: "#eff6ff",
sidebarActiveColor: "#2563eb",
sidebarHoverBackground: "#f8fafc",
sidebarBorder: "#e2e8f0",
sidebarWidth: "280px",
sidebarCollapsedWidth: "80px",
modalAccentColor: "#2563eb",
modalHeaderBackground: "#f8fafc",
modalHeaderEditBackground: "#eff6ff",
modalIconBackground: "#eff6ff",
modalIconColor: "#2563eb",
modalTitleColor: "#1e293b",
modalFocusColor: "#2563eb",
modalSurface: "#ffffff",
},
};
const UM_RUNTIME = {
basename: '/um',
basename: "/um",
// Keep the embedded IAM module on the same API as the backoffice client.
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
// fall back to its remote development server, where the local JWT is
// rejected and the module redirects to its login page.
apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
};
const buttonStyle: React.CSSProperties = {
position: 'fixed',
position: "fixed",
top: 12,
left: 12,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
gap: 6,
padding: '8px 16px',
border: '1px solid #e2e8f0',
padding: "8px 16px",
border: "1px solid #e2e8f0",
borderRadius: 8,
background: '#ffffff',
color: '#2563eb',
background: "#ffffff",
color: "#2563eb",
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'all 150ms ease',
cursor: "pointer",
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
transition: "all 150ms ease",
};
export default function UserManagementPage() {
@@ -133,29 +137,31 @@ export default function UserManagementPage() {
const navigate = useNavigate();
const handleReturn = useCallback(() => {
navigate('/dashboard');
navigate("/dashboard");
}, [navigate]);
useEffect(() => {
if (!containerRef.current) return;
const style = document.createElement('style');
const style = document.createElement("style");
style.textContent = UM_OVERRIDES;
document.head.appendChild(style);
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
const token = Cookies.get("ema-backoffice-auth-token") ?? "";
const refreshToken = Cookies.get("ema-backoffice-refresh-token");
const session: UserManagementSessionOptions = {
initialSession: token
? { token, refreshToken, rememberMe: true }
: null,
initialSession: token ? { token, refreshToken, rememberMe: true } : null,
enableEmbeddedAuthBridge: false,
};
rootRef.current = createRoot(containerRef.current);
rootRef.current.render(
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
<UserManagementApp
config={UM_CONFIG}
runtime={UM_RUNTIME}
session={session}
/>,
);
return () => {
@@ -173,21 +179,28 @@ export default function UserManagementPage() {
onClick={handleReturn}
style={buttonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
e.currentTarget.style.background = "#f8fafc";
e.currentTarget.style.boxShadow = "0 1px 6px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ffffff';
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
e.currentTarget.style.background = "#ffffff";
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.08)";
}}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Return to EMA
</button>
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
<div ref={containerRef} style={{ position: "fixed", inset: 0 }} />
</>
);
}

View File

@@ -17,7 +17,7 @@ export default defineConfig({
// port: 4201,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },

View File

@@ -22,7 +22,7 @@ own ports, against its own database:
| Backoffice | 4303 | same |
| Database | — | `ema_e2e` |
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
This is deliberate. A developer's stack is usually already up on 3000/3001/4201,
and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps'
`.env.local` on every run — a suite that read those files would point at
whichever stack was started last. The API is launched with `DATABASE_NAME`,

View File

@@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string {
});
}
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();

View File

@@ -603,6 +603,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: "ስም (እንግሊዝኛ) ያስፈልጋል",

View File

@@ -603,6 +603,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',

View File

@@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute";
import {
LoginPage,
SignupPage,
FaydaCallbackPage,
OTPVerificationPage,
ForgotPasswordPage,
SetPasswordPage,
@@ -68,6 +69,16 @@ export const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },
// Where Fayda returns the applicant. Public by necessity — they have no
// account yet. It redeems the code and hands control back to /signup.
//
// Two paths for one page: whichever is registered with Fayda has to match the
// API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a
// bare /callback. The descriptive path is kept so the route still reads as
// part of signup once that can be changed.
{ path: "/signup/fayda/callback", element: <FaydaCallbackPage /> },
{ path: "/callback", element: <FaydaCallbackPage /> },
// 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.

View File

@@ -1,31 +1,34 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
export default defineConfig({
root: __dirname,
// Env lives at the workspace root, shared with the backoffice — without this
// Vite looks in apps/portal and VITE_BASE_API_URL silently falls back to its
// built-in default.
envDir: '../../',
cacheDir: '../../node_modules/.vite/apps/portal',
server: { port: 4200, host: 'localhost' },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 4200, host: 'localhost' },
envDir: "../../",
cacheDir: "../../node_modules/.vite/apps/portal",
// 3000, not the usual 4200: the Fayda redirect URI registered for local
// testing is http://localhost:3001/callback, and the provider matches it
// exactly. The API moves to 3001 to make room.
server: { port: 3000, host: "localhost" },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 3000, host: "localhost" },
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
},
build: {
outDir: '../../dist/apps/portal',
outDir: "../../dist/apps/portal",
emptyOutDir: true,
reportCompressedSize: true,
},

View File

@@ -8,5 +8,5 @@ export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-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;