Adding more functionalities for all the license types

This commit is contained in:
Mulu Mehari
2026-08-07 11:50:07 +03:00
parent 35fb817b4b
commit 7c968c7093
66 changed files with 6468 additions and 3518 deletions

View File

@@ -2,4 +2,7 @@ export * from './lib/base-api';
export * from './lib/query-and-mutation';
export * from './lib/session';
export * from './lib/features/licensing';
export * from './lib/features/seafarer';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument } from './lib/base-api/download';

View File

@@ -2,7 +2,7 @@ import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { resolveSessionContext } from '../session';
const BASE_API_URL =
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';

View File

@@ -0,0 +1,43 @@
import { resolveTokenFromStorage } from '../session';
import { BASE_API_URL } from './base-query-with-reauth';
/**
* Opens an authenticated binary endpoint (a rendered PDF) in a new tab.
*
* RTK Query is not used here: `fetchBaseQuery` parses responses as JSON, and
* a plain `window.open` sends no Authorization header, so a guarded document
* endpoint would answer 401. Fetching to a blob keeps the bearer token on the
* request and still gives the browser something it can display.
*/
export async function openAuthedDocument(
path: string,
fallbackName = 'document.pdf',
): Promise<void> {
const token = resolveTokenFromStorage();
const response = await fetch(`${BASE_API_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
// The body carries the API's error key, which callers surface verbatim.
let message = `${response.status}`;
try {
const body = await response.json();
message = body?.message ?? message;
} catch {
/* non-JSON error body — the status is all we have */
}
throw new Error(message);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const opened = window.open(url, '_blank', 'noopener');
if (!opened) {
// Pop-up blocked: fall back to a direct download so the click still works.
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fallbackName;
anchor.click();
}
// Revoking immediately would race the new tab's load.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}

View File

@@ -308,6 +308,39 @@ export const licensingApi = baseApi
}),
// ------------------------------------------------------------ licences
/**
* Public QR-code verification (US-PORTAL-008). No auth required —
* the endpoint returns only what a verifier needs to trust the
* document, never the holder's contact details.
*/
verifyCertificate: builder.query<
{
valid: boolean;
reason?: string;
certificateNumber?: string;
licenseType?: string | null;
companyName?: string | null;
issueDate?: string;
expiryDate?: string;
status?: string;
},
string
>({
query: (code) => ({ url: `/licenses/verify/${code}` }),
}),
/** The licence register for enforcement officers. */
getLicenses: builder.query<
Paginated<IssuedLicense>,
{ search?: string } | void
>({
query: (args) => ({
url: '/licenses',
params: args?.search ? { search: args.search } : undefined,
}),
providesTags: () => [listTag('License')],
}),
getMyLicenses: builder.query<Paginated<IssuedLicense>, void>({
query: () => ({ url: '/licenses/mine' }),
providesTags: () => [listTag('License')],
@@ -693,12 +726,24 @@ export const licensingApi = baseApi
recordInspectionResult: builder.mutation<
Inspection,
{ inspectionId: string; applicationId: string; result: 'PASSED' | 'FAILED'; findings: string }
{
inspectionId: string;
applicationId: string;
result: 'PASSED' | 'FAILED';
findings: string;
/** Structured per-item outcomes (office, warehouse, vehicles, …). */
checklist?: {
key: string;
label: string;
outcome: 'PASS' | 'FAIL' | 'NEEDS_CORRECTION';
note?: string;
}[];
}
>({
query: ({ inspectionId, result, findings }) => ({
query: ({ inspectionId, result, findings, checklist }) => ({
url: `/inspections/${inspectionId}/result`,
method: 'PATCH',
body: { result, findings },
body: { result, findings, ...(checklist?.length ? { checklist } : {}) },
}),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
@@ -724,6 +769,7 @@ export const licensingApi = baseApi
});
export const {
useVerifyCertificateQuery,
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
@@ -736,6 +782,7 @@ export const {
useBypassPaymentMutation,
useGetPaymentCapabilitiesQuery,
useGetMyLicensesQuery,
useGetLicensesQuery,
useGetCertificateUrlMutation,
useGetApplicationPaymentQuery,
usePatchSectionMutation,

View File

@@ -18,7 +18,13 @@ const BASE_API_URL =
* attach them to, and nothing can be lost if the browser closes mid-wizard.
*/
export async function uploadDocument(params: {
ownerType: 'APPLICATION' | 'APPLICATION_STAFF' | 'INSPECTION' | 'LICENSE';
ownerType:
| 'APPLICATION'
| 'APPLICATION_STAFF'
| 'INSPECTION'
| 'LICENSE'
| 'SEA_SERVICE_RECORD'
| 'MEDICAL_CERTIFICATE';
ownerId: string;
documentKey: string;
file: File;
@@ -210,6 +216,14 @@ export interface WizardStep {
export function buildWizardSteps(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
options?: {
/**
* Whether this licence type has staff-role requirements at all. Types
* without any (seafarer registration) skip the Staff step entirely
* instead of showing an empty page.
*/
hasStaff?: boolean;
},
): WizardStep[] {
const visible = [...sections]
.filter((section) => conditionHolds(section.showWhen, formData))
@@ -259,7 +273,9 @@ export function buildWizardSteps(
return [
...formSteps,
{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] },
...(options?.hasStaff === false
? []
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
];

View File

@@ -77,7 +77,8 @@ export interface FormSectionConfig {
export type LicenseCategory =
| 'CARGO_FREIGHT'
| 'SHIPPING_AGENCY'
| 'INVESTMENT';
| 'INVESTMENT'
| 'MARITIME_PERSONNEL';
export interface LicenseCategoryDefinition {
key: LicenseCategory;
@@ -119,6 +120,12 @@ export interface LicenseType {
inspectionRequired: boolean;
issuesCertificate: boolean;
renewalEnabled: boolean;
/**
* False for person-centric registrations (seafarer): they are open to any
* authenticated applicant, live outside the operator catalogue, and have
* their own entry points.
*/
requiresOperatorMode: boolean;
formSchema: { sections: FormSectionConfig[] };
isActive: boolean;
/** Display order set by EMA; lower comes first. */

View File

@@ -0,0 +1,2 @@
export * from './seafarer.types';
export * from './seafarer-api';

View File

@@ -0,0 +1,188 @@
import { baseApi } from '../../base-api';
import type {
CreateMedicalCertificate,
CreateSeaServiceRecord,
MedicalCertificate,
SeaServiceRecord,
SeaTimeSummary,
SeafarerStatus,
} from './seafarer.types';
const TAGS = ['SeaServiceRecord', 'MedicalCertificate'] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
/**
* The seafarer's evidence shelf: sea-service records and medical
* certificates (modules 05/06). Registration itself rides the licensing
* endpoints — a SEAFARER_REGISTRATION application through `licensingApi`.
*/
export const seafarerApi = baseApi
.enhanceEndpoints({ addTagTypes: TAGS })
.injectEndpoints({
endpoints: (builder) => ({
// ---------------------------------------------------------- sea service
getMySeaServiceRecords: builder.query<SeaServiceRecord[], void>({
query: () => ({ url: '/sea-service-records/mine' }),
providesTags: () => [listTag('SeaServiceRecord')],
}),
getSeaServiceForProfile: builder.query<SeaServiceRecord[], string>({
query: (profileId) => ({
url: `/sea-service-records/profile/${profileId}`,
}),
providesTags: () => [listTag('SeaServiceRecord')],
}),
createSeaServiceRecord: builder.mutation<
SeaServiceRecord,
CreateSeaServiceRecord
>({
query: (body) => ({ url: '/sea-service-records', method: 'POST', body }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
updateSeaServiceRecord: builder.mutation<
SeaServiceRecord,
{ id: string; body: Partial<CreateSeaServiceRecord> }
>({
query: ({ id, body }) => ({
url: `/sea-service-records/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
deleteSeaServiceRecord: builder.mutation<unknown, string>({
query: (id) => ({ url: `/sea-service-records/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
// -------------------------------------------------------------- medical
getMyMedicalCertificates: builder.query<MedicalCertificate[], void>({
query: () => ({ url: '/medical-certificates/mine' }),
providesTags: () => [listTag('MedicalCertificate')],
}),
getMedicalForProfile: builder.query<MedicalCertificate[], string>({
query: (profileId) => ({
url: `/medical-certificates/profile/${profileId}`,
}),
providesTags: () => [listTag('MedicalCertificate')],
}),
createMedicalCertificate: builder.mutation<
MedicalCertificate,
CreateMedicalCertificate
>({
query: (body) => ({ url: '/medical-certificates', method: 'POST', body }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
updateMedicalCertificate: builder.mutation<
MedicalCertificate,
{ id: string; body: Partial<CreateMedicalCertificate> }
>({
query: ({ id, body }) => ({
url: `/medical-certificates/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
deleteMedicalCertificate: builder.mutation<unknown, string>({
query: (id) => ({ url: `/medical-certificates/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
// -------------------------------------------------------- verification
getPendingSeaService: builder.query<SeaServiceRecord[], void>({
query: () => ({ url: '/sea-service-records/pending' }),
providesTags: () => [listTag('SeaServiceRecord')],
}),
getPendingMedical: builder.query<MedicalCertificate[], void>({
query: () => ({ url: '/medical-certificates/pending' }),
providesTags: () => [listTag('MedicalCertificate')],
}),
verifySeaServiceRecord: builder.mutation<
SeaServiceRecord,
{ id: string; outcome: 'VERIFIED' | 'REJECTED'; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/sea-service-records/${id}/verify`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('SeaServiceRecord')],
}),
verifyMedicalCertificate: builder.mutation<
MedicalCertificate,
{ id: string; outcome: 'VERIFIED' | 'REJECTED'; remark?: string }
>({
query: ({ id, ...body }) => ({
url: `/medical-certificates/${id}/verify`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('MedicalCertificate')],
}),
getMySeaTime: builder.query<SeaTimeSummary, void>({
query: () => ({ url: '/sea-service-records/mine/sea-time' }),
providesTags: () => [listTag('SeaServiceRecord')],
}),
getSeaTimeForProfile: builder.query<SeaTimeSummary, string>({
query: (profileId) => ({
url: `/sea-service-records/profile/${profileId}/sea-time`,
}),
providesTags: () => [listTag('SeaServiceRecord')],
}),
// ----------------------------------------------- registry (backoffice)
/** US-SEA-013: suspend, reinstate or close with a mandatory reason. */
updateSeafarerStatus: builder.mutation<
unknown,
{ profileId: string; status: SeafarerStatus; reason: string }
>({
query: ({ profileId, ...body }) => ({
url: `/profiles/${profileId}/seafarer-status`,
method: 'POST',
body,
}),
}),
}),
});
export const {
useGetPendingSeaServiceQuery,
useGetPendingMedicalQuery,
useVerifySeaServiceRecordMutation,
useVerifyMedicalCertificateMutation,
useGetMySeaTimeQuery,
useGetSeaTimeForProfileQuery,
useGetMySeaServiceRecordsQuery,
useGetSeaServiceForProfileQuery,
useCreateSeaServiceRecordMutation,
useUpdateSeaServiceRecordMutation,
useDeleteSeaServiceRecordMutation,
useGetMyMedicalCertificatesQuery,
useGetMedicalForProfileQuery,
useCreateMedicalCertificateMutation,
useUpdateMedicalCertificateMutation,
useDeleteMedicalCertificateMutation,
useUpdateSeafarerStatusMutation,
} = seafarerApi;

View File

@@ -0,0 +1,78 @@
export type SeafarerRecordStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED';
export type MedicalFitness = 'FIT' | 'FIT_WITH_RESTRICTIONS' | 'UNFIT';
export type SeafarerDepartment = 'DECK' | 'ENGINE' | 'CATERING';
export type SeafarerStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED';
/** One engagement aboard one vessel (US-SSM-001). */
/** Denormalised owner summary, present on the verification queues. */
export interface SeafarerProfileSummary {
id: string;
firstName: string | null;
middleName: string | null;
lastName: string | null;
seafarerNumber?: string | null;
}
export interface SeaServiceRecord {
id: string;
profileId: string;
profile?: SeafarerProfileSummary;
vesselName: string;
imoNumber: string | null;
vesselType: string | null;
flagState: string | null;
grossTonnage: string | number | null;
rank: string;
engagementDate: string;
dischargeDate: string;
dutiesDescription: string | null;
status: SeafarerRecordStatus;
verifiedById: string | null;
verifiedAt: string | null;
verificationRemark: string | null;
createdAt: string;
}
export interface CreateSeaServiceRecord {
vesselName: string;
imoNumber?: string;
vesselType?: string;
flagState?: string;
grossTonnage?: number;
rank: string;
engagementDate: string;
dischargeDate: string;
dutiesDescription?: string;
}
/** An STCW medical fitness certificate (US-SSM-006). */
export interface MedicalCertificate {
id: string;
profileId: string;
profile?: SeafarerProfileSummary;
issuerName: string;
certificateNumber: string | null;
issueDate: string;
expiryDate: string;
fitnessStatus: MedicalFitness;
restrictions: string | null;
status: SeafarerRecordStatus;
verifiedById: string | null;
verifiedAt: string | null;
verificationRemark: string | null;
createdAt: string;
}
export interface CreateMedicalCertificate {
issuerName: string;
certificateNumber?: string;
issueDate: string;
expiryDate: string;
fitnessStatus?: MedicalFitness;
restrictions?: string;
}
export interface SeaTimeSummary {
totalDays: number;
verifiedRecords: number;
}

View File

@@ -0,0 +1,2 @@
export * from './vessel.types';
export * from './vessel-api';

View File

@@ -0,0 +1,84 @@
import { baseApi } from '../../base-api';
import type {
CreateVesselIncident,
Vessel,
VesselIncident,
VesselStatus,
} from './vessel.types';
const TAGS = ['Vessel', 'VesselIncident'] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
/**
* The vessel register (module 11). Registration applications themselves go
* through `licensingApi` as the VESSEL_REGISTRATION type; these endpoints
* serve the register the certificates produce.
*/
export const vesselApi = baseApi
.enhanceEndpoints({ addTagTypes: TAGS })
.injectEndpoints({
endpoints: (builder) => ({
getMyVessels: builder.query<Vessel[], void>({
query: () => ({ url: '/vessels/mine' }),
providesTags: () => [listTag('Vessel')],
}),
getVessels: builder.query<
{ total: number; items: Vessel[] },
{ search?: string } | void
>({
query: (args) => ({
url: '/vessels',
params: args?.search ? { search: args.search } : undefined,
}),
providesTags: () => [listTag('Vessel')],
}),
getVessel: builder.query<Vessel, string>({
query: (id) => ({ url: `/vessels/${id}` }),
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
}),
/** Suspend / deregister / reinstate with a mandatory reason. */
updateVesselStatus: builder.mutation<
Vessel,
{ vesselId: string; status: VesselStatus; reason: string }
>({
query: ({ vesselId, ...body }) => ({
url: `/vessels/${vesselId}/status`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error, { vesselId }) =>
error ? [] : [listTag('Vessel'), { type: 'Vessel', id: vesselId }],
}),
getVesselIncidents: builder.query<VesselIncident[], string>({
query: (vesselId) => ({ url: `/vessels/${vesselId}/incidents` }),
providesTags: () => [listTag('VesselIncident')],
}),
createVesselIncident: builder.mutation<
VesselIncident,
{ vesselId: string; body: CreateVesselIncident }
>({
query: ({ vesselId, body }) => ({
url: `/vessels/${vesselId}/incidents`,
method: 'POST',
body,
}),
invalidatesTags: (_r, error) =>
error ? [] : [listTag('VesselIncident')],
}),
}),
});
export const {
useGetMyVesselsQuery,
useGetVesselsQuery,
useGetVesselQuery,
useUpdateVesselStatusMutation,
useGetVesselIncidentsQuery,
useCreateVesselIncidentMutation,
} = vesselApi;

View File

@@ -0,0 +1,51 @@
export type VesselCategory = 'INLAND_WATERWAY' | 'SEA_GOING';
export type VesselStatus = 'REGISTERED' | 'SUSPENDED' | 'DEREGISTERED';
/** A vessel-register entry, written when a registration certificate issues. */
export interface Vessel {
id: string;
registrationNumber: string;
name: string;
category: VesselCategory;
vesselType: string | null;
imoNumber: string | null;
hullNumber: string | null;
flagState: string | null;
portOfRegistry: string | null;
grossTonnage: string | number | null;
passengerCapacity: number | null;
lengthMeters: string | number | null;
yearBuilt: number | null;
engineType: string | null;
enginePowerKw: string | number | null;
numberOfEngines: number | null;
hullMaterial: string | null;
ownerUserId: string;
ownerProfileId: string | null;
ownerName: string | null;
applicationId: string;
licenseId: string;
status: VesselStatus;
statusReason: string | null;
statusChangedAt: string | null;
registeredAt: string;
}
export interface VesselIncident {
id: string;
vesselId: string;
occurredAt: string;
location: string | null;
description: string;
severity: string | null;
reportedById: string;
reportedByOfficer: boolean;
createdAt: string;
}
export interface CreateVesselIncident {
occurredAt: string;
location?: string;
description: string;
severity?: string;
}

View File

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

View File

@@ -46,6 +46,9 @@ export function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [serverError, setServerError] = useState<string | null>(null);
// MFA second step: set once /auth/login answers `mfaRequired` (US-IAM-006).
const [mfaEmail, setMfaEmail] = useState<string | null>(null);
const [mfaOtp, setMfaOtp] = useState('');
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
@@ -66,7 +69,49 @@ export function LoginPage() {
method: 'POST',
body: values,
}).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({
url: '/auth/me',
@@ -92,26 +137,17 @@ 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;
}
navigate(loginRedirectPath);
} 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);
if (!me.isPhoneNumberVerified) {
navigate('/otp-verify', {
state: {
email: me.email,
phoneNumber: me.phoneNumber,
},
});
return;
}
navigate(loginRedirectPath);
};
return (
@@ -132,6 +168,42 @@ export function LoginPage() {
</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)}>
<Stack gap="md">
<TextInput
@@ -181,6 +253,7 @@ export function LoginPage() {
</Button>
</Stack>
</form>
)}
<Divider label="or" labelPosition="center" />

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Card,
Center,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconInfoCircle, IconLockCheck } from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
/** Mirrors the API's IsStrongPassword rule so failures are explained locally. */
function passwordProblem(password: string): string | null {
if (password.length < 8) return 'At least 8 characters';
if (!/[a-z]/.test(password)) return 'At least one lowercase letter';
if (!/[0-9]/.test(password)) return 'At least one number';
if (!/[^A-Za-z0-9]/.test(password)) return 'At least one symbol';
return null;
}
/**
* Completes the forgot-password flow (US-IAM-007).
*
* The reset message carries a link to this page with `userId` and `email` —
* the set-password endpoint requires both, and the account holder only knows
* one of them. Arriving without the link means the code alone cannot finish
* the reset, and the page says so instead of failing cryptically.
*/
export function SetPasswordPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const userId = params.get('userId') ?? '';
const email = params.get('email') ?? '';
// The IAM package's link carries the code as `verificationCode`.
const [code, setCode] = useState(
params.get('verificationCode') ?? params.get('code') ?? '',
);
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const [setPasswordTrigger, { isLoading }] = useApiMutation();
const linkMissing = !userId || !email;
const problem = password ? passwordProblem(password) : null;
const submit = async () => {
setError(null);
if (problem) return setError(`Password needs: ${problem.toLowerCase()}`);
if (password !== confirm) return setError("Passwords don't match");
try {
await setPasswordTrigger({
url: '/auth/set-password',
method: 'PATCH',
body: {
userId,
email,
verificationCode: code,
newPassword: password,
confirmPassword: confirm,
},
}).unwrap();
notify.success('Password updated — sign in with your new password');
navigate('/login');
} catch (err) {
const message = (err as { data?: { message?: string } })?.data?.message;
setError(
typeof message === 'string' && message === 'verification_code_expired'
? 'The code has expired. Request a new reset from the sign-in page.'
: 'Could not set the password. Check the code and try again.',
);
}
};
return (
<Center mih="100vh" p="md">
<Card withBorder radius="md" p="xl" w={420}>
<Stack>
<Stack gap={4} align="center">
<IconLockCheck size={32} color="var(--mantine-color-blue-6)" />
<Title order={3}>Set a new password</Title>
{email && (
<Text size="sm" c="dimmed">
for {email}
</Text>
)}
</Stack>
{linkMissing ? (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
Open this page from the reset link we sent you the link carries
the details needed to finish the reset. You can request one from
the{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/forgot-password')}
>
forgot-password page
</Text>
.
</Alert>
) : (
<>
<TextInput
label="Verification code"
placeholder="The code from the reset message"
required
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
/>
<PasswordInput
label="New password"
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
error={password && problem ? problem : undefined}
/>
<PasswordInput
label="Confirm password"
required
value={confirm}
onChange={(e) => setConfirm(e.currentTarget.value)}
error={
confirm && confirm !== password
? "Passwords don't match"
: undefined
}
/>
{error && <Alert color="red">{error}</Alert>}
<Button
fullWidth
loading={isLoading}
disabled={!code.trim() || !password || !confirm}
onClick={submit}
>
Set password
</Button>
</>
)}
</Stack>
</Card>
</Center>
);
}

View File

@@ -26,6 +26,8 @@ export interface LoginPayload {
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
/** Set (with no tokens) when the account requires an OTP second factor. */
mfaRequired?: boolean;
}
export interface CurrentProfileAddress {
@@ -73,6 +75,14 @@ export interface CurrentProfile {
pob: string;
maritalStatus: string;
isComplete: boolean;
/**
* Registered-seafarer identity — written by the platform when a seafarer
* registration is approved, null before that.
*/
seafarerNumber?: string | null;
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;
user: AuthUser;
address: CurrentProfileAddress;
profession: CurrentProfileProfession;