diff --git a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx index 334de6e20..502b6c59b 100644 --- a/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx +++ b/apps/backoffice/src/app/features/question/pages/QuestionPage.tsx @@ -15,6 +15,7 @@ import { Select, NumberInput, Textarea, + Table, } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useTranslation } from 'react-i18next'; @@ -26,7 +27,7 @@ import { IconSend, IconGavel, } from '@tabler/icons-react'; -import { notify } from '@ema-platform/ui'; +import { AdvancedColumn, ModalFooter, notify } from '@ema-platform/ui'; import { extractErrorMessage } from '@ema-platform/api'; import { useGetCertificationsQuery } from '../../certification/api/certification-api'; import { diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx index 919df14d7..1952936c4 100644 --- a/apps/portal/src/app/router.tsx +++ b/apps/portal/src/app/router.tsx @@ -1,79 +1,91 @@ -import { createBrowserRouter, Navigate } from 'react-router-dom'; -import { I18nextProvider } from 'react-i18next'; -import { i18n } from './i18n/config'; -import { PortalLayout } from './layouts/PortalLayout'; -import { ProtectedRoute } from './components/ProtectedRoute'; +import { createBrowserRouter, Navigate } from "react-router-dom"; +import { I18nextProvider } from "react-i18next"; +import { i18n } from "./i18n/config"; +import { PortalLayout } from "./layouts/PortalLayout"; +import { ProtectedRoute } from "./components/ProtectedRoute"; // Auth (standalone pages, no portal chrome) -import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage, SetPasswordPage } from '@ema-platform/auth'; +import { + LoginPage, + SignupPage, + OTPVerificationPage, + ForgotPasswordPage, + SetPasswordPage, +} from "@ema-platform/auth"; // Portal feature pages -import { DashboardPage } from './features/dashboard/pages/DashboardPage'; -import { RequireOperations } from './features/onboarding/components/RequireOperations'; -import { OperationsOnboardingPage } from './features/onboarding/pages/OperationsOnboardingPage'; -import { ProfilePage } from './features/profile/pages/ProfilePage'; -import { SupportPage } from './features/support/pages/SupportPage'; -import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage'; -import { MySeaRecordsPage } from './features/seafarer/pages/MySeaRecordsPage'; -import { VerifyCertificatePage } from './features/verify/pages/VerifyCertificatePage'; -import { ExamsPage } from './features/exams/pages/ExamsPage'; +import { DashboardPage } from "./features/dashboard/pages/DashboardPage"; +import { RequireOperations } from "./features/onboarding/components/RequireOperations"; +import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage"; +import { ProfilePage } from "./features/profile/pages/ProfilePage"; +import { SupportPage } from "./features/support/pages/SupportPage"; +import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage"; +import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage"; +import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage"; +import { ExamsPage } from "./features/exams/pages/ExamsPage"; // Phase 1 pages -import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage'; -import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage'; -import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage'; -import { NotificationsPage } from './features/notifications/pages/NotificationsPage'; +import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage"; +import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage"; +import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage"; +import { NotificationsPage } from "./features/notifications/pages/NotificationsPage"; // Phase 2 — CoC / CoP -import { CertificatesPage } from './features/certificates/pages/CertificatesPage'; -import { CoCApplicationPage } from './features/certificates/pages/CoCApplicationPage'; +import { CertificatesPage } from "./features/certificates/pages/CertificatesPage"; +import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage"; // Phase 3 — Endorsement -import { EndorsementPage } from './features/endorsement/pages/EndorsementPage'; -import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage'; -import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage'; -import { MyApplicationsPage } from './features/licensing/pages/MyApplicationsPage'; -import { PaymentCheckPage } from './features/payments/pages/PaymentCheckPage'; -import { PaymentSuccessPage } from './features/payments/pages/PaymentSuccessPage'; -import { PaymentFailurePage } from './features/payments/pages/PaymentFailurePage'; -import { LicenseApplicationPage } from './features/licensing/pages/LicenseApplicationPage'; -import { ApplicationRedirectPage } from './features/licensing/pages/ApplicationRedirectPage'; -import { WaiverPage } from './features/waiver/pages/WaiverPage'; - +import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage"; +import { VesselRegistrationPage } from "./features/vessel-registration/pages/VesselRegistrationPage"; +import { OwnershipTransferPage } from "./features/vessel-registration/pages/OwnershipTransferPage"; +import { MyApplicationsPage } from "./features/licensing/pages/MyApplicationsPage"; +import { PaymentCheckPage } from "./features/payments/pages/PaymentCheckPage"; +import { PaymentSuccessPage } from "./features/payments/pages/PaymentSuccessPage"; +import { PaymentFailurePage } from "./features/payments/pages/PaymentFailurePage"; +import { LicenseApplicationPage } from "./features/licensing/pages/LicenseApplicationPage"; +import { ApplicationRedirectPage } from "./features/licensing/pages/ApplicationRedirectPage"; +import { WaiverPage } from "./features/waiver/pages/WaiverPage"; import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage"; - export const router = createBrowserRouter([ // Public auth pages - { path: '/login', element: }, - { path: '/signup', element: }, + { path: "/login", element: }, + { path: "/signup", element: }, // Public certificate verification — the target of every printed QR code. // No auth: a verifier scanning a certificate has no portal account. - { path: '/verify', element: }, - { path: '/verify/:code', element: }, + { path: "/verify", element: }, + { path: "/verify/:code", 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. - { path: '/set-password', element: }, - { path: '/reset-password', element: }, + { path: "/set-password", element: }, + { path: "/reset-password", element: }, // Protected auth pages { - element: , - path: '/otp-verify', + element: ( + + + + ), + path: "/otp-verify", }, { - element: , - path: '/forgot-password', + element: ( + + + + ), + path: "/forgot-password", }, // The two-step setup wizard is gone. Signing up lands on the dashboard, and // profile details are collected where they are actually needed: on /profile, // via the dashboard nudge, or inline in an application flow. The path stays // as a redirect so existing bookmarks and emailed links do not 404. - { path: '/profile-setup', element: }, + { path: "/profile-setup", element: }, // Portal — protected. { @@ -89,83 +101,159 @@ export const router = createBrowserRouter([ ), children: [ - { path: '/', element: }, - { path: '/dashboard', element: }, - { path: '/onboarding/operations', element: }, + { path: "/", element: }, + { path: "/dashboard", element: }, + { path: "/onboarding/operations", element: }, // Config-driven licensing: one set of pages serves every licence type. - { path: '/licensing/applications', element: }, + { path: "/licensing/applications", element: }, // Telebirr returns the applicant to these. - { path: '/payments/check', element: }, - { path: '/payments/success', element: }, - { path: '/payments/failure', element: }, - { path: '/licensing/:typeCode/apply', element: }, + { path: "/payments/check", element: }, + { path: "/payments/success", element: }, + { path: "/payments/failure", element: }, { - path: '/licensing/:typeCode/applications/:applicationId', + path: "/licensing/:typeCode/apply", + element: , + }, + { + path: "/licensing/:typeCode/applications/:applicationId", element: , }, // Notification / email deep links arrive as /applications/; resolve the // licence type and forward to the canonical route. - { path: '/applications/:applicationId', element: }, + { + path: "/applications/:applicationId", + element: , + }, // Seafarer - { path: '/seafarer-registration', element: }, - { path: '/seafarer/records', element: }, - { path: '/exams', element: }, + { path: "/seafarer-registration", element: }, + { path: "/seafarer/records", element: }, + { path: "/exams", element: }, // The public-facing registry was a hardcoded mock and does not belong in // the applicant portal; officers browse seafarers in the backoffice. - { path: '/seafarer-registry', element: }, - { path: '/seafarer-registry/:id', element: }, + { + path: "/seafarer-registry", + element: , + }, + { + path: "/seafarer-registry/:id", + element: , + }, // Phase 1 - { path: '/documents', element: }, - { path: '/seaman-book', element: }, - { path: '/seaman-book/apply', element: }, - { path: '/notifications', element: }, + { path: "/documents", element: }, + { path: "/seaman-book", element: }, + { path: "/seaman-book/apply", element: }, + { path: "/notifications", element: }, // Phase 2 — CoC / CoP - { path: '/certificates', element: }, - { path: '/certificates/apply', element: }, + { path: "/certificates", element: }, + { path: "/certificates/apply", element: }, // Phase 3 — Endorsement - { path: '/endorsements', element: }, - { path: '/vessel-registration', element: }, + { path: "/endorsements", element: }, + { path: "/vessel-registration", element: }, // The registration wizard is the config-driven licensing flow; the old // standalone wizard posted to endpoints that never existed. - { path: '/vessel-registration/apply', element: }, - { path: '/vessel-registration-dashboard', element: }, - { path: '/vessel-registration/transfer', element: }, + { + path: "/vessel-registration/apply", + element: , + }, + { + path: "/vessel-registration-dashboard", + element: , + }, + { + path: "/vessel-registration/transfer", + element: , + }, // Legacy per-licence-type URLs. Each once had its own hand-written page // that posted to a `/logistics-licenses/*` endpoint the API never had, // and dropped every uploaded document on the floor. They are kept as // redirects so old bookmarks land somewhere real; the config-driven // wizard below serves every licence type from one place. - { path: '/logistics-dashboard', element: }, - { path: '/freight-forwarder-license', element: }, - { path: '/freight-forwarder-license/apply', element: }, - { path: '/freight-forwarder-license/:id/renew', element: }, - { path: '/shipping-agent-license', element: }, - { path: '/shipping-agent-license/apply', element: }, - { path: '/shipping-agent-license/:id/renew', element: }, - { path: '/combined-license', element: }, - { path: '/combined-license/apply', element: }, - { path: '/combined-license/:id/renew', element: }, - { path: '/joint-investment-license', element: }, - { path: '/joint-investment-license/apply', element: }, - { path: '/joint-investment-license/:id/renew', element: }, - { path: '/mto-license', element: }, - { path: '/mto-license/apply', element: }, - { path: '/mto-license/:id/renew', element: }, + { + path: "/logistics-dashboard", + element: , + }, + { + path: "/freight-forwarder-license", + element: , + }, + { + path: "/freight-forwarder-license/apply", + element: , + }, + { + path: "/freight-forwarder-license/:id/renew", + element: , + }, + { + path: "/shipping-agent-license", + element: , + }, + { + path: "/shipping-agent-license/apply", + element: , + }, + { + path: "/shipping-agent-license/:id/renew", + element: , + }, + { + path: "/combined-license", + element: , + }, + { + path: "/combined-license/apply", + element: , + }, + { + path: "/combined-license/:id/renew", + element: , + }, + { + path: "/joint-investment-license", + element: , + }, + { + path: "/joint-investment-license/apply", + element: , + }, + { + path: "/joint-investment-license/:id/renew", + element: , + }, + { + path: "/mto-license", + element: , + }, + { + path: "/mto-license/apply", + element: ( + + ), + }, + { + path: "/mto-license/:id/renew", + element: , + }, // Waiver has no backend yet, so it says so rather than pretending. - { path: '/waiver', element: }, - { path: '/waiver/apply', element: }, + { path: "/waiver", element: }, + { path: "/waiver/apply", element: }, // Vessel Registration - { path: '/vessel-registrations', element: }, - { path: '/vessel-registrations/apply', element: }, - { path: '/vessel-registrations/:id', element: }, - + { path: "/vessel-registrations", element: }, + // { path: '/vessel-registrations/apply', element: }, + { + path: "/vessel-registrations/:id", + element: , + }, // General { path: "/profile", element: }, @@ -175,12 +263,27 @@ export const router = createBrowserRouter([ // The separate vessel-owner login/portal was mock-only and called auth // endpoints that never existed; vessel owners are ordinary portal users. - { path: '/vessel-owner/login', element: }, - { path: '/vessel-owner/register', element: }, - { path: '/vessel-owner/dashboard', element: }, - { path: '/vessel-owner/registration', element: }, - { path: '/vessel-owner/registration/apply', element: }, - { path: '/vessel-owner/registration/transfer', element: }, + { path: "/vessel-owner/login", element: }, + { + path: "/vessel-owner/register", + element: , + }, + { + path: "/vessel-owner/dashboard", + element: , + }, + { + path: "/vessel-owner/registration", + element: , + }, + { + path: "/vessel-owner/registration/apply", + element: , + }, + { + path: "/vessel-owner/registration/transfer", + element: , + }, - { path: '*', element: }, + { path: "*", element: }, ]); diff --git a/apps/portal/src/app/store/index.ts b/apps/portal/src/app/store/index.ts index 58ddfaf7c..f68e7bfaa 100644 --- a/apps/portal/src/app/store/index.ts +++ b/apps/portal/src/app/store/index.ts @@ -1,6 +1,5 @@ import { configureStore } from "@reduxjs/toolkit"; import { baseApi, configureTokenRefresh } from "@ema-platform/api"; -import { setToken } from "@ema-platform/auth"; import { authReducer, signupReducer, @@ -8,6 +7,7 @@ import { authStorage, refreshAccessToken, logout, + setToken, } from "@ema-platform/auth"; import type { AuthUser, CurrentProfile } from "@ema-platform/auth"; diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index eac397353..b8ecc7986 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -1,17 +1,31 @@ -export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig'; -export type { AuthConfigValue } from './lib/AuthConfig'; -export { AuthShell, BrandMark } from './lib/components/AuthShell'; -export { ProtectedRoute } from './lib/components/ProtectedRoute'; -export { AuthBootstrap } from './lib/components/AuthBootstrap'; -export { LoginPage } from './lib/pages/LoginPage'; -export { SignupPage } from './lib/pages/SignupPage'; -export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage'; -export { SetPasswordPage } from './lib/pages/SetPasswordPage'; -export { OTPVerificationPage } from './lib/pages/OTPVerificationPage'; -export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice'; -export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice'; -export { usePermissions } from './lib/hooks/usePermissions'; -export type { PermissionSet } from './lib/hooks/usePermissions'; +export { AuthConfigProvider, useAuthConfig } from "./lib/AuthConfig"; +export type { AuthConfigValue } from "./lib/AuthConfig"; +export { AuthShell, BrandMark } from "./lib/components/AuthShell"; +export { ProtectedRoute } from "./lib/components/ProtectedRoute"; +export { AuthBootstrap } from "./lib/components/AuthBootstrap"; +export { LoginPage } from "./lib/pages/LoginPage"; +export { SignupPage } from "./lib/pages/SignupPage"; +export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage"; +export { SetPasswordPage } from "./lib/pages/SetPasswordPage"; +export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage"; +export { + authReducer, + loginSuccess, + setUser, + setCurrentProfile, + clearCurrentProfile, + logout, + hydrateAuth, + setToken, +} from "./lib/store/auth.slice"; +export { + signupReducer, + setSignupData, + setSignupStep, + resetSignup, +} from "./lib/store/signup.slice"; +export { usePermissions } from "./lib/hooks/usePermissions"; +export type { PermissionSet } from "./lib/hooks/usePermissions"; export { useCurrentProfile, useGetMyProfileQuery, diff --git a/libs/auth/src/lib/pages/LoginPage.tsx b/libs/auth/src/lib/pages/LoginPage.tsx index 13347c548..502a4b18f 100644 --- a/libs/auth/src/lib/pages/LoginPage.tsx +++ b/libs/auth/src/lib/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState } from "react"; import { Alert, Anchor, @@ -11,29 +11,56 @@ import { Text, TextInput, Title, -} from '@mantine/core'; +} from "@mantine/core"; import { IconArrowRight, IconDeviceMobile, IconLock, IconMail, -} from '@tabler/icons-react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useNavigate, Link } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; -import { useApiMutation } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; -import { AuthShell } from '../components/AuthShell'; -import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice'; -import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types'; -import { useAuthConfig } from '../AuthConfig'; -import { authStorage } from '../utils/auth-storage'; +} from "@tabler/icons-react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useNavigate, Link } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { useApiMutation } from "@ema-platform/api"; +import { notify, useErrorHandler } from "@ema-platform/ui"; +import { AuthShell } from "../components/AuthShell"; +import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice"; +import type { + LoginPayload, + AuthUser, + CurrentProfile, +} from "../types/auth.types"; +import { useAuthConfig } from "../AuthConfig"; +import { authStorage } from "../utils/auth-storage"; +const emailOrPhone = z + .string() + .trim() + .transform((value) => { + // Convert 09xxxxxxxx -> +2519xxxxxxxx + if (/^09\d{8}$/.test(value)) { + return `+251${value.substring(1)}`; + } + return value; + }) + .refine( + (value) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const phoneRegex = /^\+2519\d{8}$/; + + return emailRegex.test(value) || phoneRegex.test(value); + }, + { + message: "Enter a valid email or phone number (+2519xxxxxxxx)", + }, + ); const schema = z.object({ - email: z.string().email({ message: 'Enter a valid email' }), - password: z.string().min(5, { message: 'Password must be at least 6 characters' }), + email: emailOrPhone, + password: z + .string() + .min(8, { message: "Password must be at least 8 characters" }), }); type FormValues = z.infer; @@ -46,9 +73,7 @@ export function LoginPage() { const [isLoading, setIsLoading] = useState(false); const [rememberMe, setRememberMe] = useState(true); const [serverError, setServerError] = useState(null); - // MFA second step: set once /auth/login answers `mfaRequired` (US-IAM-006). - const [mfaEmail, setMfaEmail] = useState(null); - const [mfaOtp, setMfaOtp] = useState(''); + const { handleError } = useErrorHandler(); const [loginTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>(); @@ -65,57 +90,15 @@ export function LoginPage() { setIsLoading(true); try { const data = await loginTrigger({ - url: '/auth/login', - method: 'POST', + url: "/auth/login", + method: "POST", body: values, }).unwrap(); - - // MFA-enabled accounts get no tokens yet — an OTP has been sent, and - // the session only exists once /auth/mfa-verify accepts it (US-IAM-006). - if (data.mfaRequired) { - setMfaEmail(values.email); - notify.success('Enter the verification code we just sent you'); - return; - } - await completeSession(data); - } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : 'Something went wrong'); - setServerError(msg); - notify.error(msg); - } finally { - setIsLoading(false); - } - }; - - const verifyMfa = async () => { - if (!mfaEmail || !mfaOtp.trim()) return; - setIsLoading(true); - try { - const data = await loginTrigger({ - url: '/auth/mfa-verify', - method: 'POST', - body: { email: mfaEmail, otp: mfaOtp.trim() }, - }).unwrap(); - // The second factor proves possession of the verified phone. - await completeSession({ ...data, isPhoneNumberVerified: true }); - } catch (err: unknown) { - const msg = - (err as { data?: { message?: string } })?.data?.message ?? - 'Verification failed'; - setServerError(msg === 'unable_to_log_in' ? 'Invalid or expired code' : msg); - } finally { - setIsLoading(false); - } - }; - - const completeSession = async (data: LoginPayload) => { - dispatch(loginSuccess(data)); + dispatch(loginSuccess(data)); const me = await meTrigger({ - url: '/auth/me', - method: 'GET', + url: "/auth/me", + method: "GET", }).unwrap(); dispatch(setUser(me)); @@ -126,8 +109,8 @@ export function LoginPage() { // `useCurrentProfile` resolves it again on demand. try { const { profile } = await profileTrigger({ - url: '/profiles/me', - method: 'GET', + url: "/profiles/me", + method: "GET", }).unwrap(); if (profile) { authStorage.setProfileId(profile.id); @@ -137,17 +120,23 @@ export function LoginPage() { // Offline or a 5xx — the portal still works; the resolver retries. } - if (!me.isPhoneNumberVerified) { - navigate('/otp-verify', { - state: { - email: me.email, - phoneNumber: me.phoneNumber, - }, - }); - return; - } + if (!me.isPhoneNumberVerified) { + navigate("/otp-verify", { + state: { + email: me.email, + phoneNumber: me.phoneNumber, + }, + }); + return; + } - navigate(loginRedirectPath); + navigate(loginRedirectPath); + } catch (err: unknown) { + setServerError(handleError(err)); + } finally { + localStorage.setItem("rememberMe", String(rememberMe)); + setIsLoading(false); + } }; return ( @@ -163,47 +152,16 @@ export function LoginPage() { {serverError && ( - setServerError(null)}> + setServerError(null)} + > {serverError} )} - {mfaEmail ? ( - - }> - This account requires a second factor. Enter the code we sent to - your registered phone. - - setMfaOtp(e.currentTarget.value)} - onKeyDown={(e) => e.key === 'Enter' && verifyMfa()} - /> - - { - setMfaEmail(null); - setMfaOtp(''); - setServerError(null); - }} - > - Back to sign in - - - ) : (
} error={errors.email?.message} - {...register('email')} + {...register("email")} /> } error={errors.password?.message} - {...register('password')} + {...register("password")} /> @@ -253,23 +211,9 @@ export function LoginPage() {
- )} - - - - - {enableSignup && ( - Don't have an account?{' '} + Don't have an account?{" "} Create one @@ -278,4 +222,4 @@ export function LoginPage() { ); -} \ No newline at end of file +}