mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Refactor imports and improve LoginPage validation logic
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
|
Table,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -26,7 +27,7 @@ import {
|
|||||||
IconSend,
|
IconSend,
|
||||||
IconGavel,
|
IconGavel,
|
||||||
} from '@tabler/icons-react';
|
} 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 { extractErrorMessage } from '@ema-platform/api';
|
||||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,79 +1,91 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { i18n } from './i18n/config';
|
import { i18n } from "./i18n/config";
|
||||||
import { PortalLayout } from './layouts/PortalLayout';
|
import { PortalLayout } from "./layouts/PortalLayout";
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
|
||||||
// Auth (standalone pages, no portal chrome)
|
// 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
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
||||||
import { RequireOperations } from './features/onboarding/components/RequireOperations';
|
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
||||||
import { OperationsOnboardingPage } from './features/onboarding/pages/OperationsOnboardingPage';
|
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||||
import { SupportPage } from './features/support/pages/SupportPage';
|
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
|
||||||
import { MySeaRecordsPage } from './features/seafarer/pages/MySeaRecordsPage';
|
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||||
import { VerifyCertificatePage } from './features/verify/pages/VerifyCertificatePage';
|
import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
|
||||||
import { ExamsPage } from './features/exams/pages/ExamsPage';
|
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||||
|
|
||||||
// Phase 1 pages
|
// Phase 1 pages
|
||||||
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
|
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||||
import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage';
|
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
|
||||||
import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage';
|
import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage";
|
||||||
import { NotificationsPage } from './features/notifications/pages/NotificationsPage';
|
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
|
||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
import { CertificatesPage } from './features/certificates/pages/CertificatesPage';
|
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||||
import { CoCApplicationPage } from './features/certificates/pages/CoCApplicationPage';
|
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||||
import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage';
|
import { VesselRegistrationPage } from "./features/vessel-registration/pages/VesselRegistrationPage";
|
||||||
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
|
import { OwnershipTransferPage } from "./features/vessel-registration/pages/OwnershipTransferPage";
|
||||||
import { MyApplicationsPage } from './features/licensing/pages/MyApplicationsPage';
|
import { MyApplicationsPage } from "./features/licensing/pages/MyApplicationsPage";
|
||||||
import { PaymentCheckPage } from './features/payments/pages/PaymentCheckPage';
|
import { PaymentCheckPage } from "./features/payments/pages/PaymentCheckPage";
|
||||||
import { PaymentSuccessPage } from './features/payments/pages/PaymentSuccessPage';
|
import { PaymentSuccessPage } from "./features/payments/pages/PaymentSuccessPage";
|
||||||
import { PaymentFailurePage } from './features/payments/pages/PaymentFailurePage';
|
import { PaymentFailurePage } from "./features/payments/pages/PaymentFailurePage";
|
||||||
import { LicenseApplicationPage } from './features/licensing/pages/LicenseApplicationPage';
|
import { LicenseApplicationPage } from "./features/licensing/pages/LicenseApplicationPage";
|
||||||
import { ApplicationRedirectPage } from './features/licensing/pages/ApplicationRedirectPage';
|
import { ApplicationRedirectPage } from "./features/licensing/pages/ApplicationRedirectPage";
|
||||||
import { WaiverPage } from './features/waiver/pages/WaiverPage';
|
import { WaiverPage } from "./features/waiver/pages/WaiverPage";
|
||||||
|
|
||||||
|
|
||||||
import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage";
|
import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage";
|
||||||
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
// Public auth pages
|
// Public auth pages
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
{ path: '/signup', element: <SignupPage /> },
|
{ path: "/signup", element: <SignupPage /> },
|
||||||
|
|
||||||
// Public certificate verification — the target of every printed QR code.
|
// Public certificate verification — the target of every printed QR code.
|
||||||
// No auth: a verifier scanning a certificate has no portal account.
|
// No auth: a verifier scanning a certificate has no portal account.
|
||||||
{ path: '/verify', element: <VerifyCertificatePage /> },
|
{ path: "/verify", element: <VerifyCertificatePage /> },
|
||||||
{ path: '/verify/:code', element: <VerifyCertificatePage /> },
|
{ path: "/verify/:code", element: <VerifyCertificatePage /> },
|
||||||
|
|
||||||
// Completes the forgot-password flow; the reset message links here. The
|
// Completes the forgot-password flow; the reset message links here. The
|
||||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||||
// first-time-credential variant — one page serves both.
|
// first-time-credential variant — one page serves both.
|
||||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
{ path: "/set-password", element: <SetPasswordPage /> },
|
||||||
{ path: '/reset-password', element: <SetPasswordPage /> },
|
{ path: "/reset-password", element: <SetPasswordPage /> },
|
||||||
|
|
||||||
// Protected auth pages
|
// Protected auth pages
|
||||||
{
|
{
|
||||||
element: <ProtectedRoute><OTPVerificationPage /></ProtectedRoute>,
|
element: (
|
||||||
path: '/otp-verify',
|
<ProtectedRoute>
|
||||||
|
<OTPVerificationPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
path: "/otp-verify",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
element: (
|
||||||
path: '/forgot-password',
|
<ProtectedRoute>
|
||||||
|
<ForgotPasswordPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
path: "/forgot-password",
|
||||||
},
|
},
|
||||||
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
// 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,
|
// profile details are collected where they are actually needed: on /profile,
|
||||||
// via the dashboard nudge, or inline in an application flow. The path stays
|
// 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.
|
// as a redirect so existing bookmarks and emailed links do not 404.
|
||||||
{ path: '/profile-setup', element: <Navigate to="/profile" replace /> },
|
{ path: "/profile-setup", element: <Navigate to="/profile" replace /> },
|
||||||
|
|
||||||
// Portal — protected.
|
// Portal — protected.
|
||||||
{
|
{
|
||||||
@@ -89,83 +101,159 @@ export const router = createBrowserRouter([
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
{ path: "/", element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: "/dashboard", element: <DashboardPage /> },
|
||||||
{ path: '/onboarding/operations', element: <OperationsOnboardingPage /> },
|
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
|
||||||
|
|
||||||
// Config-driven licensing: one set of pages serves every licence type.
|
// Config-driven licensing: one set of pages serves every licence type.
|
||||||
{ path: '/licensing/applications', element: <MyApplicationsPage /> },
|
{ path: "/licensing/applications", element: <MyApplicationsPage /> },
|
||||||
|
|
||||||
// Telebirr returns the applicant to these.
|
// Telebirr returns the applicant to these.
|
||||||
{ path: '/payments/check', element: <PaymentCheckPage /> },
|
{ path: "/payments/check", element: <PaymentCheckPage /> },
|
||||||
{ path: '/payments/success', element: <PaymentSuccessPage /> },
|
{ path: "/payments/success", element: <PaymentSuccessPage /> },
|
||||||
{ path: '/payments/failure', element: <PaymentFailurePage /> },
|
{ path: "/payments/failure", element: <PaymentFailurePage /> },
|
||||||
{ path: '/licensing/:typeCode/apply', element: <LicenseApplicationPage /> },
|
|
||||||
{
|
{
|
||||||
path: '/licensing/:typeCode/applications/:applicationId',
|
path: "/licensing/:typeCode/apply",
|
||||||
|
element: <LicenseApplicationPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/licensing/:typeCode/applications/:applicationId",
|
||||||
element: <LicenseApplicationPage />,
|
element: <LicenseApplicationPage />,
|
||||||
},
|
},
|
||||||
// Notification / email deep links arrive as /applications/<id>; resolve the
|
// Notification / email deep links arrive as /applications/<id>; resolve the
|
||||||
// licence type and forward to the canonical route.
|
// licence type and forward to the canonical route.
|
||||||
{ path: '/applications/:applicationId', element: <ApplicationRedirectPage /> },
|
{
|
||||||
|
path: "/applications/:applicationId",
|
||||||
|
element: <ApplicationRedirectPage />,
|
||||||
|
},
|
||||||
|
|
||||||
// Seafarer
|
// Seafarer
|
||||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
{ path: "/seafarer-registration", element: <SeafarerRegistrationPage /> },
|
||||||
{ path: '/seafarer/records', element: <MySeaRecordsPage /> },
|
{ path: "/seafarer/records", element: <MySeaRecordsPage /> },
|
||||||
{ path: '/exams', element: <ExamsPage /> },
|
{ path: "/exams", element: <ExamsPage /> },
|
||||||
// The public-facing registry was a hardcoded mock and does not belong in
|
// The public-facing registry was a hardcoded mock and does not belong in
|
||||||
// the applicant portal; officers browse seafarers in the backoffice.
|
// the applicant portal; officers browse seafarers in the backoffice.
|
||||||
{ path: '/seafarer-registry', element: <Navigate to="/seafarer-registration" replace /> },
|
{
|
||||||
{ path: '/seafarer-registry/:id', element: <Navigate to="/seafarer-registration" replace /> },
|
path: "/seafarer-registry",
|
||||||
|
element: <Navigate to="/seafarer-registration" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/seafarer-registry/:id",
|
||||||
|
element: <Navigate to="/seafarer-registration" replace />,
|
||||||
|
},
|
||||||
|
|
||||||
// Phase 1
|
// Phase 1
|
||||||
{ path: '/documents', element: <DocumentVaultPage /> },
|
{ path: "/documents", element: <DocumentVaultPage /> },
|
||||||
{ path: '/seaman-book', element: <SeamanBookPage /> },
|
{ path: "/seaman-book", element: <SeamanBookPage /> },
|
||||||
{ path: '/seaman-book/apply', element: <SeamanBookApplicationPage /> },
|
{ path: "/seaman-book/apply", element: <SeamanBookApplicationPage /> },
|
||||||
{ path: '/notifications', element: <NotificationsPage /> },
|
{ path: "/notifications", element: <NotificationsPage /> },
|
||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
{ path: '/certificates', element: <CertificatesPage /> },
|
{ path: "/certificates", element: <CertificatesPage /> },
|
||||||
{ path: '/certificates/apply', element: <CoCApplicationPage /> },
|
{ path: "/certificates/apply", element: <CoCApplicationPage /> },
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
{ path: "/endorsements", element: <EndorsementPage /> },
|
||||||
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
|
{ path: "/vessel-registration", element: <VesselRegistrationPage /> },
|
||||||
// The registration wizard is the config-driven licensing flow; the old
|
// The registration wizard is the config-driven licensing flow; the old
|
||||||
// standalone wizard posted to endpoints that never existed.
|
// standalone wizard posted to endpoints that never existed.
|
||||||
{ path: '/vessel-registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
|
{
|
||||||
{ path: '/vessel-registration-dashboard', element: <Navigate to="/vessel-registration" replace /> },
|
path: "/vessel-registration/apply",
|
||||||
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
|
element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/vessel-registration-dashboard",
|
||||||
|
element: <Navigate to="/vessel-registration" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/vessel-registration/transfer",
|
||||||
|
element: <OwnershipTransferPage />,
|
||||||
|
},
|
||||||
// Legacy per-licence-type URLs. Each once had its own hand-written page
|
// Legacy per-licence-type URLs. Each once had its own hand-written page
|
||||||
// that posted to a `/logistics-licenses/*` endpoint the API never had,
|
// that posted to a `/logistics-licenses/*` endpoint the API never had,
|
||||||
// and dropped every uploaded document on the floor. They are kept as
|
// and dropped every uploaded document on the floor. They are kept as
|
||||||
// redirects so old bookmarks land somewhere real; the config-driven
|
// redirects so old bookmarks land somewhere real; the config-driven
|
||||||
// wizard below serves every licence type from one place.
|
// wizard below serves every licence type from one place.
|
||||||
{ path: '/logistics-dashboard', element: <Navigate to="/dashboard" replace /> },
|
{
|
||||||
{ path: '/freight-forwarder-license', element: <Navigate to="/licensing/applications" replace /> },
|
path: "/logistics-dashboard",
|
||||||
{ path: '/freight-forwarder-license/apply', element: <Navigate to="/licensing/FREIGHT_FORWARDER/apply" replace /> },
|
element: <Navigate to="/dashboard" replace />,
|
||||||
{ path: '/freight-forwarder-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
},
|
||||||
{ path: '/shipping-agent-license', element: <Navigate to="/licensing/applications" replace /> },
|
{
|
||||||
{ path: '/shipping-agent-license/apply', element: <Navigate to="/licensing/SHIPPING_AGENT/apply" replace /> },
|
path: "/freight-forwarder-license",
|
||||||
{ path: '/shipping-agent-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
{ path: '/combined-license', element: <Navigate to="/licensing/applications" replace /> },
|
},
|
||||||
{ path: '/combined-license/apply', element: <Navigate to="/licensing/COMBINED_SA_FF/apply" replace /> },
|
{
|
||||||
{ path: '/combined-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
path: "/freight-forwarder-license/apply",
|
||||||
{ path: '/joint-investment-license', element: <Navigate to="/licensing/applications" replace /> },
|
element: <Navigate to="/licensing/FREIGHT_FORWARDER/apply" replace />,
|
||||||
{ path: '/joint-investment-license/apply', element: <Navigate to="/licensing/JOINT_INVESTOR/apply" replace /> },
|
},
|
||||||
{ path: '/joint-investment-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
{
|
||||||
{ path: '/mto-license', element: <Navigate to="/licensing/applications" replace /> },
|
path: "/freight-forwarder-license/:id/renew",
|
||||||
{ path: '/mto-license/apply', element: <Navigate to="/licensing/MULTIMODAL_TRANSPORT_OPERATOR/apply" replace /> },
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
{ path: '/mto-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
},
|
||||||
|
{
|
||||||
|
path: "/shipping-agent-license",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/shipping-agent-license/apply",
|
||||||
|
element: <Navigate to="/licensing/SHIPPING_AGENT/apply" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/shipping-agent-license/:id/renew",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/combined-license",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/combined-license/apply",
|
||||||
|
element: <Navigate to="/licensing/COMBINED_SA_FF/apply" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/combined-license/:id/renew",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/joint-investment-license",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/joint-investment-license/apply",
|
||||||
|
element: <Navigate to="/licensing/JOINT_INVESTOR/apply" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/joint-investment-license/:id/renew",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/mto-license",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/mto-license/apply",
|
||||||
|
element: (
|
||||||
|
<Navigate
|
||||||
|
to="/licensing/MULTIMODAL_TRANSPORT_OPERATOR/apply"
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/mto-license/:id/renew",
|
||||||
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
|
},
|
||||||
// Waiver has no backend yet, so it says so rather than pretending.
|
// Waiver has no backend yet, so it says so rather than pretending.
|
||||||
{ path: '/waiver', element: <WaiverPage /> },
|
{ path: "/waiver", element: <WaiverPage /> },
|
||||||
{ path: '/waiver/apply', element: <Navigate to="/waiver" replace /> },
|
{ path: "/waiver/apply", element: <Navigate to="/waiver" replace /> },
|
||||||
|
|
||||||
// Vessel Registration
|
// Vessel Registration
|
||||||
{ path: '/vessel-registrations', element: <VesselRegistrationPage /> },
|
{ path: "/vessel-registrations", element: <VesselRegistrationPage /> },
|
||||||
{ path: '/vessel-registrations/apply', element: <VesselRegistrationApplicationPage /> },
|
// { path: '/vessel-registrations/apply', element: <VesselRegistrationPage /> },
|
||||||
{ path: '/vessel-registrations/:id', element: <VesselRegistrationStatusPage /> },
|
{
|
||||||
|
path: "/vessel-registrations/:id",
|
||||||
|
element: <VesselRegistrationStatusPage />,
|
||||||
|
},
|
||||||
|
|
||||||
// General
|
// General
|
||||||
{ path: "/profile", element: <ProfilePage /> },
|
{ path: "/profile", element: <ProfilePage /> },
|
||||||
@@ -175,12 +263,27 @@ export const router = createBrowserRouter([
|
|||||||
|
|
||||||
// The separate vessel-owner login/portal was mock-only and called auth
|
// The separate vessel-owner login/portal was mock-only and called auth
|
||||||
// endpoints that never existed; vessel owners are ordinary portal users.
|
// endpoints that never existed; vessel owners are ordinary portal users.
|
||||||
{ path: '/vessel-owner/login', element: <Navigate to="/login" replace /> },
|
{ path: "/vessel-owner/login", element: <Navigate to="/login" replace /> },
|
||||||
{ path: '/vessel-owner/register', element: <Navigate to="/signup" replace /> },
|
{
|
||||||
{ path: '/vessel-owner/dashboard', element: <Navigate to="/vessel-registration" replace /> },
|
path: "/vessel-owner/register",
|
||||||
{ path: '/vessel-owner/registration', element: <Navigate to="/vessel-registration" replace /> },
|
element: <Navigate to="/signup" replace />,
|
||||||
{ path: '/vessel-owner/registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
|
},
|
||||||
{ path: '/vessel-owner/registration/transfer', element: <Navigate to="/vessel-registration/transfer" replace /> },
|
{
|
||||||
|
path: "/vessel-owner/dashboard",
|
||||||
|
element: <Navigate to="/vessel-registration" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/vessel-owner/registration",
|
||||||
|
element: <Navigate to="/vessel-registration" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/vessel-owner/registration/apply",
|
||||||
|
element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/vessel-owner/registration/transfer",
|
||||||
|
element: <Navigate to="/vessel-registration/transfer" replace />,
|
||||||
|
},
|
||||||
|
|
||||||
{ path: '*', element: <Navigate to="/" replace /> },
|
{ path: "*", element: <Navigate to="/" replace /> },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
import { baseApi, configureTokenRefresh } from "@ema-platform/api";
|
import { baseApi, configureTokenRefresh } from "@ema-platform/api";
|
||||||
import { setToken } from "@ema-platform/auth";
|
|
||||||
import {
|
import {
|
||||||
authReducer,
|
authReducer,
|
||||||
signupReducer,
|
signupReducer,
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
authStorage,
|
authStorage,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
logout,
|
logout,
|
||||||
|
setToken,
|
||||||
} from "@ema-platform/auth";
|
} from "@ema-platform/auth";
|
||||||
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
|
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
|
export { AuthConfigProvider, useAuthConfig } from "./lib/AuthConfig";
|
||||||
export type { AuthConfigValue } from './lib/AuthConfig';
|
export type { AuthConfigValue } from "./lib/AuthConfig";
|
||||||
export { AuthShell, BrandMark } from './lib/components/AuthShell';
|
export { AuthShell, BrandMark } from "./lib/components/AuthShell";
|
||||||
export { ProtectedRoute } from './lib/components/ProtectedRoute';
|
export { ProtectedRoute } from "./lib/components/ProtectedRoute";
|
||||||
export { AuthBootstrap } from './lib/components/AuthBootstrap';
|
export { AuthBootstrap } from "./lib/components/AuthBootstrap";
|
||||||
export { LoginPage } from './lib/pages/LoginPage';
|
export { LoginPage } from "./lib/pages/LoginPage";
|
||||||
export { SignupPage } from './lib/pages/SignupPage';
|
export { SignupPage } from "./lib/pages/SignupPage";
|
||||||
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
|
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
|
||||||
export { SetPasswordPage } from './lib/pages/SetPasswordPage';
|
export { SetPasswordPage } from "./lib/pages/SetPasswordPage";
|
||||||
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
|
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";
|
||||||
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
|
export {
|
||||||
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
|
authReducer,
|
||||||
export { usePermissions } from './lib/hooks/usePermissions';
|
loginSuccess,
|
||||||
export type { PermissionSet } from './lib/hooks/usePermissions';
|
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 {
|
export {
|
||||||
useCurrentProfile,
|
useCurrentProfile,
|
||||||
useGetMyProfileQuery,
|
useGetMyProfileQuery,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
@@ -11,29 +11,56 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconDeviceMobile,
|
IconDeviceMobile,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconMail,
|
IconMail,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from "react-redux";
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from "@ema-platform/api";
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, useErrorHandler } from "@ema-platform/ui";
|
||||||
import { AuthShell } from '../components/AuthShell';
|
import { AuthShell } from "../components/AuthShell";
|
||||||
import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice';
|
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
|
||||||
import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types';
|
import type {
|
||||||
import { useAuthConfig } from '../AuthConfig';
|
LoginPayload,
|
||||||
import { authStorage } from '../utils/auth-storage';
|
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({
|
const schema = z.object({
|
||||||
email: z.string().email({ message: 'Enter a valid email' }),
|
email: emailOrPhone,
|
||||||
password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, { message: "Password must be at least 8 characters" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
@@ -46,9 +73,7 @@ export function LoginPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(true);
|
const [rememberMe, setRememberMe] = useState(true);
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
// MFA second step: set once /auth/login answers `mfaRequired` (US-IAM-006).
|
const { handleError } = useErrorHandler();
|
||||||
const [mfaEmail, setMfaEmail] = useState<string | null>(null);
|
|
||||||
const [mfaOtp, setMfaOtp] = useState('');
|
|
||||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||||
@@ -65,57 +90,15 @@ export function LoginPage() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await loginTrigger({
|
const data = await loginTrigger({
|
||||||
url: '/auth/login',
|
url: "/auth/login",
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: values,
|
body: values,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
dispatch(loginSuccess(data));
|
||||||
// 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));
|
|
||||||
|
|
||||||
const me = await meTrigger({
|
const me = await meTrigger({
|
||||||
url: '/auth/me',
|
url: "/auth/me",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
dispatch(setUser(me));
|
dispatch(setUser(me));
|
||||||
|
|
||||||
@@ -126,8 +109,8 @@ export function LoginPage() {
|
|||||||
// `useCurrentProfile` resolves it again on demand.
|
// `useCurrentProfile` resolves it again on demand.
|
||||||
try {
|
try {
|
||||||
const { profile } = await profileTrigger({
|
const { profile } = await profileTrigger({
|
||||||
url: '/profiles/me',
|
url: "/profiles/me",
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
if (profile) {
|
if (profile) {
|
||||||
authStorage.setProfileId(profile.id);
|
authStorage.setProfileId(profile.id);
|
||||||
@@ -137,17 +120,23 @@ export function LoginPage() {
|
|||||||
// Offline or a 5xx — the portal still works; the resolver retries.
|
// Offline or a 5xx — the portal still works; the resolver retries.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!me.isPhoneNumberVerified) {
|
if (!me.isPhoneNumberVerified) {
|
||||||
navigate('/otp-verify', {
|
navigate("/otp-verify", {
|
||||||
state: {
|
state: {
|
||||||
email: me.email,
|
email: me.email,
|
||||||
phoneNumber: me.phoneNumber,
|
phoneNumber: me.phoneNumber,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(loginRedirectPath);
|
navigate(loginRedirectPath);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setServerError(handleError(err));
|
||||||
|
} finally {
|
||||||
|
localStorage.setItem("rememberMe", String(rememberMe));
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,47 +152,16 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{serverError && (
|
{serverError && (
|
||||||
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
withCloseButton
|
||||||
|
onClose={() => setServerError(null)}
|
||||||
|
>
|
||||||
{serverError}
|
{serverError}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaEmail ? (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert variant="light" color="blue" icon={<IconDeviceMobile size={18} />}>
|
|
||||||
This account requires a second factor. Enter the code we sent to
|
|
||||||
your registered phone.
|
|
||||||
</Alert>
|
|
||||||
<TextInput
|
|
||||||
label="Verification code"
|
|
||||||
placeholder="6-digit code"
|
|
||||||
size="md"
|
|
||||||
value={mfaOtp}
|
|
||||||
onChange={(e) => setMfaOtp(e.currentTarget.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && verifyMfa()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="md"
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={!mfaOtp.trim()}
|
|
||||||
onClick={verifyMfa}
|
|
||||||
rightSection={<IconArrowRight size={18} />}
|
|
||||||
>
|
|
||||||
Verify and sign in
|
|
||||||
</Button>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
ta="center"
|
|
||||||
onClick={() => {
|
|
||||||
setMfaEmail(null);
|
|
||||||
setMfaOtp('');
|
|
||||||
setServerError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back to sign in
|
|
||||||
</Anchor>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -212,7 +170,7 @@ export function LoginPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
leftSection={<IconMail size={18} />}
|
leftSection={<IconMail size={18} />}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
{...register('email')}
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Password"
|
label="Password"
|
||||||
@@ -220,7 +178,7 @@ export function LoginPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
leftSection={<IconLock size={18} />}
|
leftSection={<IconLock size={18} />}
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
{...register('password')}
|
{...register("password")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
@@ -253,23 +211,9 @@ export function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider label="or" labelPosition="center" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
leftSection={<IconDeviceMobile size={18} />}
|
|
||||||
onClick={() => notify.info('Phone sign-in is coming soon.')}
|
|
||||||
>
|
|
||||||
Sign in with phone number
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{enableSignup && (
|
{enableSignup && (
|
||||||
<Text ta="center" size="sm" c="dimmed">
|
<Text ta="center" size="sm" c="dimmed">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{" "}
|
||||||
<Anchor component={Link} to="/signup" fw={700}>
|
<Anchor component={Link} to="/signup" fw={700}>
|
||||||
Create one
|
Create one
|
||||||
</Anchor>
|
</Anchor>
|
||||||
@@ -278,4 +222,4 @@ export function LoginPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user