Refactor imports and improve LoginPage validation logic

This commit is contained in:
Estifo77
2026-08-08 09:26:47 +03:00
parent a92580c7be
commit 23675005fc
5 changed files with 311 additions and 249 deletions

View File

@@ -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 {

View File

@@ -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 /> },
]); ]);

View File

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

View File

@@ -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,

View File

@@ -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&apos;t have an account?{' '} Don&apos;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>
); );
} }