diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..eed81c69a --- /dev/null +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore index 4b15bf545..8b70b3e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ coverage/ # env .env .env.* +# ...but the checked-in template must survive that rule. +!.env.example # logs *.log diff --git a/README.md b/README.md index 361b9afd8..9b3ae76c1 100644 --- a/README.md +++ b/README.md @@ -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// ├── types/ # TypeScript interfaces diff --git a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts index e9f4d2892..115ea32e6 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts @@ -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 }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3000/api'; +export const API_BASE_URL = BASE_API_URL; export const STATUS_COLOR: Record = { DRAFT: 'gray', diff --git a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx index a3e3d8f90..7cd82395f 100644 --- a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx +++ b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx @@ -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( - , + , ); 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)'; - }} - > - + e.currentTarget.style.background = "#ffffff"; + e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.08)"; + }}> + Return to EMA -
+
); } diff --git a/apps/backoffice/vite.config.mts b/apps/backoffice/vite.config.mts index 355a26fdf..84f66adb4 100644 --- a/apps/backoffice/vite.config.mts +++ b/apps/backoffice/vite.config.mts @@ -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, // }, // }, diff --git a/apps/e2e/README.md b/apps/e2e/README.md index cfec73a58..86c75a6da 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -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`, diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index c15fa983b..f7cf52743 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string { }); } -const API_BASE = - (import.meta as { env?: Record }).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 { const token = authStorage.getToken(); diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 19ec22e23..1777a853d 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -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: "ስም (እንግሊዝኛ) ያስፈልጋል", diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 28d6eae28..b1ff4b7c3 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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', diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 70938af0e..fa369fff5 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, @@ -68,6 +69,16 @@ 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. + // + // 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: }, + { path: "/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/apps/portal/vite.config.mts b/apps/portal/vite.config.mts index 4e08b10fe..12221a195 100644 --- a/apps/portal/vite.config.mts +++ b/apps/portal/vite.config.mts @@ -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, }, diff --git a/libs/api/src/index.ts b/libs/api/src/index.ts index 558be9a7a..8bf4d7a69 100644 --- a/libs/api/src/index.ts +++ b/libs/api/src/index.ts @@ -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'; diff --git a/libs/api/src/lib/base-api/base-query-with-reauth.ts b/libs/api/src/lib/base-api/base-query-with-reauth.ts index c47336cd8..8b9feb2cb 100644 --- a/libs/api/src/lib/base-api/base-query-with-reauth.ts +++ b/libs/api/src/lib/base-api/base-query-with-reauth.ts @@ -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 }).env?.[ "VITE_BASE_API_URL" - ] ?? "http://localhost:3000/api"; + ]?.trim() || "http://localhost:3001/api"; let _onTokenExpired: (() => Promise) | null = null; let _onAuthFailure: (() => void) | null = null; diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index d349fa4d2..6e35ec979 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -11,9 +11,7 @@ import type { ValidationIssue, } from './licensing.types'; -const BASE_API_URL = - (import.meta as { env?: Record }).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. diff --git a/libs/api/src/lib/session/index.ts b/libs/api/src/lib/session/index.ts index 69277fd91..9c41ec927 100644 --- a/libs/api/src/lib/session/index.ts +++ b/libs/api/src/lib/session/index.ts @@ -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: 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/components/AuthBootstrap.tsx b/libs/auth/src/lib/components/AuthBootstrap.tsx index 251db18c0..264738c00 100644 --- a/libs/auth/src/lib/components/AuthBootstrap.tsx +++ b/libs/auth/src/lib/components/AuthBootstrap.tsx @@ -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 }).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. diff --git a/libs/auth/src/lib/pages/FaydaCallbackPage.tsx b/libs/auth/src/lib/pages/FaydaCallbackPage.tsx new file mode 100644 index 000000000..81314beb2 --- /dev/null +++ b/libs/auth/src/lib/pages/FaydaCallbackPage.tsx @@ -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(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/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 ( + + + {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..3b41c45de 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'; @@ -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(); + // Fayda is optional: the form below works exactly as before without it. + const [fayda, setFayda] = useState(() => 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

, where Badge's default

is invalid HTML. + 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 +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() { )} + {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 +379,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 +398,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..0dda61b00 --- /dev/null +++ b/libs/auth/src/lib/utils/fayda-session.ts @@ -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(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), +}; diff --git a/libs/auth/src/lib/utils/refresh-token.ts b/libs/auth/src/lib/utils/refresh-token.ts index 4b6997f8a..6ceb275cf 100644 --- a/libs/auth/src/lib/utils/refresh-token.ts +++ b/libs/auth/src/lib/utils/refresh-token.ts @@ -1,9 +1,6 @@ import { authStorage } from "./auth-storage"; -const BASE_API_URL = - (import.meta as { env?: Record }).env?.[ - "VITE_BASE_API_URL" - ] ?? "http://localhost:3000/api"; +import { BASE_API_URL } from "@ema-platform/api"; interface RefreshResponse { token: string;