Merge remote-tracking branch 'origin/dev' into fix/coc-exam-workflow-defects

# Conflicts:
#	apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx
#	libs/api/src/lib/features/licensing/licensing-api.ts
#	libs/api/src/lib/features/licensing/licensing.types.ts
This commit is contained in:
mihretue
2026-08-31 09:16:24 +00:00
101 changed files with 8172 additions and 2077 deletions

View File

@@ -6,6 +6,8 @@ export * from './lib/features/location';
export * from './lib/features/seafarer';
export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/personal-document';
export * from './lib/features/biometric-enrollment';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { resolveSessionContext } from "../session";
/**
* The one place the backend URL is resolved: VITE_BASE_API_URL from the env,
* falling back to the local dev API (3001 — the portal itself owns 3000 for
* the Fayda redirect). Import this; do not re-derive it.
*/
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
]?.trim() || "http://localhost:3000/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -172,6 +172,15 @@ const handlers: MockHandler[] = [
return detail ? clone(detail) : undefined;
},
},
{
method: 'DELETE',
pattern: /^\/license-applications\/([\w-]+)$/,
respond: (_req, match) => {
delete mockApplications[match[1]];
delete mockApplicationDetails[match[1]];
return { deleted: true };
},
},
{
method: 'PATCH',
pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/,

View File

@@ -0,0 +1,67 @@
import { baseApi } from '../../base-api';
import type { BiometricEnrollment, EnrollBiometric } from './biometric-enrollment.types';
const TAG = 'BiometricEnrollment' as const;
const forProfile = (profileId: string) => ({ type: TAG, id: profileId }) as const;
/**
* Scanner capture (fingerprint, face) stored per profile. No applicant-facing
* endpoint — enrollment happens at a counter with a scanner.
*/
export const biometricEnrollmentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
enrollBiometric: builder.mutation<BiometricEnrollment, EnrollBiometric>({
query: (body) => ({ url: '/biometric-enrollments', method: 'POST', body }),
invalidatesTags: (r, error) => (error || !r ? [] : [forProfile(r.profileId)]),
}),
getBiometricEnrollments: builder.query<BiometricEnrollment[], string>({
query: (profileId) => ({ url: `/biometric-enrollments/profile/${profileId}` }),
providesTags: (_r, _e, profileId) => [forProfile(profileId)],
}),
/** Self-service, view-only: the caller's own live enrollments. */
getMyBiometricEnrollments: builder.query<BiometricEnrollment[], void>({
query: () => ({ url: '/biometric-enrollments/mine' }),
providesTags: [{ type: TAG, id: 'MINE' }],
}),
/** Dev/test only — the API reports false in production. */
getBiometricSimulateCapabilities: builder.query<{ simulateEnabled: boolean }, void>({
query: () => ({ url: '/biometric-enrollments/simulate/capabilities' }),
}),
revokeBiometricEnrollment: builder.mutation<
BiometricEnrollment,
{ id: string; profileId: string; reason: string }
>({
query: ({ id, reason }) => ({
url: `/biometric-enrollments/${id}/revoke`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { profileId }) => (error ? [] : [forProfile(profileId)]),
}),
/** Stamps the profile's BSID once enrollment is confirmed. Requires an active enrollment. */
generateBsid: builder.mutation<{ id: string; bsid: string | null }, string>({
query: (profileId) => ({
url: `/biometric-enrollments/profile/${profileId}/generate-bsid`,
method: 'POST',
}),
invalidatesTags: (_r, error, profileId) => (error ? [] : [forProfile(profileId)]),
}),
}),
overrideExisting: false,
});
export const {
useEnrollBiometricMutation,
useGetBiometricEnrollmentsQuery,
useGetMyBiometricEnrollmentsQuery,
useGetBiometricSimulateCapabilitiesQuery,
useRevokeBiometricEnrollmentMutation,
useGenerateBsidMutation,
} = biometricEnrollmentApi;

View File

@@ -0,0 +1,32 @@
export type BiometricModality = 'FINGERPRINT' | 'FACE';
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
export interface BiometricEnrollment {
id: string;
profileId: string;
modality: BiometricModality;
templateFormat: string;
qualityScore: number | null;
deviceId: string | null;
status: BiometricEnrollmentStatus;
enrolledById: string;
enrolledAt: string;
consentAt: string;
revokedReason: string | null;
revokedById: string | null;
revokedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface EnrollBiometric {
profileId: string;
modality: BiometricModality;
/** Vendor SDK template, base64. Never the raw scan image. */
template: string;
templateFormat: string;
qualityScore?: number;
deviceId?: string;
/** ISO 8601 — when the subject consented to capture. */
consentAt: string;
}

View File

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

View File

@@ -14,6 +14,7 @@ import type {
InitiatePaymentResult,
IssuedLicense,
Inspection,
IssuancePeriod,
LicenseApplication,
LicenseCategoryDefinition,
LicenseStatus,
@@ -21,11 +22,16 @@ import type {
LicenseTypeRequirements,
OperatorType,
AssignableOfficer,
CertificateCategory,
CompletionEffect,
DocumentDecision,
DocumentReview,
ExportResult,
LicenseTemplate,
Paginated,
PickupAppointment,
PickupOffice,
PickupSlot,
QueueCounts,
QueueFilter,
Rank,
@@ -33,10 +39,14 @@ import type {
RemarkTargetType,
SavedQueueView,
SchemaIssue,
ServiceKind,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
TemplateVariable,
PersonalDocumentFilter,
PersonalDocumentGroup,
WorkflowProfile,
} from './licensing.types';
/**
@@ -63,6 +73,16 @@ function serialiseQueueFilter(
return params;
}
/** Sends only the facets that are set; `search=` would match nothing. */
function dropEmpty(filter: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (value === undefined || value === null || value === '' || value === false) continue;
params[key] = value;
}
return params;
}
const TAGS = [
'LicenseType',
'OperatorType',
@@ -75,8 +95,13 @@ const TAGS = [
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
'PickupOffice',
'PickupAppointment',
'Department',
'Rank',
// Owned by the personal-document slice; named here so declaring a mode of
// operation can invalidate the vault, whose slots depend on it.
'PersonalDocument',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -121,9 +146,17 @@ export const licensingApi = baseApi
method: 'PUT',
body,
}),
// The catalogue is filtered by this, so it has to refetch too.
// The catalogue is filtered by this, so it has to refetch too — and so
// is the personal document vault, which asks for the documents the
// declared modes of operation need.
invalidatesTags: (_r, error) =>
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
error
? []
: [
listTag('OperatorType'),
listTag('LicenseType'),
listTag('PersonalDocument'),
],
}),
/**
@@ -153,6 +186,12 @@ export const licensingApi = baseApi
feeNewApplication?: number | null;
feeRenewal?: number | null;
feeCurrency?: string;
// The examined-certificate stages. Unlike the two above, these are
// read live off the licence type rather than snapshotted, so the
// server refuses to clear one a candidate is currently waiting on.
feeEligibility?: number | null;
feeExamination?: number | null;
feeCertificate?: number | null;
}
>({
query: ({ id, ...body }) => ({
@@ -164,6 +203,53 @@ export const licensingApi = baseApi
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/**
* How a licence type behaves: its workflow, eligibility gates, renewal
* policy and applicant rules — everything that used to be settable only
* by editing a seed file.
*
* Three of these (`workflowProfile`, `completionEffect`,
* `requiresExamination`) decide the course an application runs, and are
* read live rather than snapshotted. The server answers 409
* `license_type_in_use` when changing one would strand applications that
* have not yet had their approval decision.
*/
updateLicenseBehavior: builder.mutation<
LicenseType,
{
id: string;
workflowProfile?: WorkflowProfile;
serviceKind?: ServiceKind;
completionEffect?: CompletionEffect | null;
certificateCategory?: CertificateCategory | null;
requiresExamination?: boolean;
inspectionRequired?: boolean;
issuesCertificate?: boolean;
renewalEnabled?: boolean;
requiresSeafarerRegistration?: boolean;
requiresValidMedical?: boolean;
minSeaTimeDays?: number | null;
validityMonths?: number;
validityDays?: number | null;
capitalThreshold?: number | null;
renewalWindowDays?: number;
expiryReminderDays?: number[];
requiresOperatorMode?: boolean;
allowMultipleOpenDrafts?: boolean;
requiresIssuanceScheduling?: boolean;
uniqueFormKeyPath?: string | null;
slaHours?: number | null;
}
>({
query: ({ id, ...body }) => ({
url: `/license-types/${id}/behavior`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/** Validity is edited beside the certificate design, not with the fees. */
updateLicenseValidity: builder.mutation<
LicenseType,
@@ -234,9 +320,30 @@ export const licensingApi = baseApi
providesTags: () => [listTag('DocumentRequirement')],
}),
/**
* Personal document slots, grouped by key and paged by the server.
*
* Its own endpoint rather than filtering `getDocumentRequirements` in the
* browser: one document can be configured against several licence types,
* so a page of rows would split a document in half and misreport its
* scope. The server groups first, then pages.
*/
getPersonalDocuments: builder.query<
Paginated<PersonalDocumentGroup>,
PersonalDocumentFilter | void
>({
query: (filter) => ({
url: '/document-requirements/personal',
params: dropEmpty(filter ?? {}),
}),
providesTags: () => [listTag('DocumentRequirement')],
}),
createDocumentRequirement: builder.mutation<
DocumentRequirement,
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
// No `licenseTypeId` means a personal document, required for every
// licence and served from the applicant's own vault.
Partial<DocumentRequirement> & { key: string; name: DocumentRequirement['name'] }
>({
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
@@ -337,6 +444,11 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
}),
discardApplication: builder.mutation<{ deleted: boolean }, string>({
query: (id) => ({ url: `/license-applications/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
}),
getMyApplications: builder.query<Paginated<LicenseApplication>, void>({
query: () => ({ url: '/license-applications/mine' }),
providesTags: () => [listTag('LicenseApplication')],
@@ -958,12 +1070,12 @@ export const licensingApi = baseApi
scheduleIssuance: builder.mutation<
LicenseApplication,
{ id: string; scheduledDate: string }
{ id: string; scheduledDate: string; scheduledPeriod: IssuancePeriod }
>({
query: ({ id, scheduledDate }) => ({
query: ({ id, scheduledDate, scheduledPeriod }) => ({
url: `/license-application-review/${id}/schedule-issuance`,
method: 'POST',
body: { scheduledDate },
body: { scheduledDate, scheduledPeriod },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
@@ -978,6 +1090,104 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// ------------------------------------------------------------- pickup
getPickupOffices: builder.query<PickupOffice[], void>({
query: () => ({ url: '/pickup/offices' }),
providesTags: [listTag('PickupOffice')],
}),
getPickupSlots: builder.query<
PickupSlot[],
{ officeId: string; from: string; to: string }
>({
query: ({ officeId, from, to }) => ({
url: `/pickup/offices/${officeId}/slots`,
params: { from, to },
}),
}),
schedulePickup: builder.mutation<
PickupAppointment,
{ applicationId: string; officeId: string; date: string; slotStartTime: string }
>({
query: (body) => ({ url: '/pickup/appointments', method: 'POST', body }),
invalidatesTags: (_r, error, { applicationId }) =>
error
? []
: [
itemTag('LicenseApplication', applicationId),
listTag('ApplicationQueue'),
listTag('PickupAppointment'),
],
}),
reschedulePickup: builder.mutation<
PickupAppointment,
{ appointmentId: string; officeId: string; date: string; slotStartTime: string }
>({
query: ({ appointmentId, ...body }) => ({
url: `/pickup/appointments/${appointmentId}/reschedule`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { appointmentId }) =>
error ? [] : [itemTag('PickupAppointment', appointmentId), listTag('PickupAppointment')],
}),
getPickupAppointmentsForApplication: builder.query<PickupAppointment[], string>({
query: (applicationId) => ({
url: `/pickup/applications/${applicationId}/appointments`,
}),
providesTags: (_r, _e, applicationId) => [itemTag('PickupAppointment', applicationId)],
}),
getPickupWorklist: builder.query<
PickupAppointment[],
{ date: string; officeId?: string }
>({
query: ({ date, officeId }) => ({
url: '/pickup/appointments',
params: officeId ? { date, officeId } : { date },
}),
providesTags: [listTag('PickupAppointment')],
}),
checkInPickup: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/check-in`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupIssued: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/issued`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupNoShow: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/no-show`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
createPickupOffice: builder.mutation<PickupOffice, Partial<PickupOffice>>({
query: (body) => ({ url: '/pickup/offices', method: 'POST', body }),
invalidatesTags: [listTag('PickupOffice')],
}),
updatePickupOffice: builder.mutation<
PickupOffice,
{ id: string } & Partial<PickupOffice>
>({
query: ({ id, ...body }) => ({
url: `/pickup/offices/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('PickupOffice', id), listTag('PickupOffice')],
}),
// --------------------------------------------------------- inspection
scheduleInspection: builder.mutation<
Inspection,
@@ -993,6 +1203,30 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
/** Moves a booked visit: another day, slot, inspector or place. */
rescheduleInspection: builder.mutation<
Inspection,
{
inspectionId: string;
applicationId: string;
scheduledDate: string;
timeSlot: 'MORNING' | 'AFTERNOON';
inspectorId?: string;
location?: string;
reason?: string;
}
>({
query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
url: `/inspections/${inspectionId}/schedule`,
method: 'PATCH',
// applicationId is for cache invalidation only; the visit knows its
// own application.
body: { scheduledDate, timeSlot, inspectorId, location, reason },
}),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
getInspections: builder.query<Inspection[], string>({
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
providesTags: () => [listTag('Inspection')],
@@ -1046,10 +1280,12 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateLicenseBehaviorMutation,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,
useGetDocumentRequirementsQuery,
useGetPersonalDocumentsQuery,
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
@@ -1067,6 +1303,7 @@ export const {
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
useGetExamStateForApplicationQuery,
useDiscardApplicationMutation,
useGetMyApplicationsQuery,
useGetApplicationQuery,
useInitiatePaymentMutation,
@@ -1127,7 +1364,19 @@ export const {
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useGetPickupOfficesQuery,
useGetPickupSlotsQuery,
useSchedulePickupMutation,
useReschedulePickupMutation,
useGetPickupAppointmentsForApplicationQuery,
useGetPickupWorklistQuery,
useCheckInPickupMutation,
useMarkPickupIssuedMutation,
useMarkPickupNoShowMutation,
useCreatePickupOfficeMutation,
useUpdatePickupOfficeMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetInspectionsQuery,
useRecordInspectionResultMutation,
useGetNotificationsQuery,

View File

@@ -1,6 +1,8 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
import type {
ApplicationKind,
Bilingual,
FamilyKind,
FieldCondition,
@@ -11,9 +13,10 @@ import type {
ValidationIssue,
} from './licensing.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/** A section with no `applicationKinds` applies to every kind, as before that field existed. */
function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind): boolean {
return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
}
/**
* Uploads a document straight to the API.
@@ -63,6 +66,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
@@ -95,6 +99,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
AWAITING_BIOMETRICS: 'indigo',
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
@@ -134,6 +139,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
DRAFT: 5,
SUBMITTED: 15,
UNDER_REVIEW: 30,
AWAITING_BIOMETRICS: 30,
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
@@ -396,6 +402,15 @@ const ERROR_MESSAGES: Record<string, string> = {
inspection_not_passed:
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
license_type_inactive: 'This licence type is not currently accepting applications.',
// Licence-type configuration guards. Each of these refuses a change that
// would leave applications already in progress unable to move.
license_type_in_use:
'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
stage_fee_in_use:
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
form_schema_missing_protected_paths:
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
};
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
@@ -451,12 +466,28 @@ export function buildWizardSteps(
* instead of showing an empty page.
*/
hasStaff?: boolean;
/**
* Whether this application has any document requirements to upload.
* False for a Damaged/Reissue application, which asks nothing beyond the
* Damage Information step — showing an empty Documents page would be a
* page to click past for nothing.
*/
hasDocuments?: boolean;
/** Active UI language. Components get this from `useLocalized`; this is a
* pure function, so the caller passes `i18n.language` through. */
language?: string;
/**
* The application's kind — NEW unless the caller is renewing or
* reissuing. A section scoped to a different kind via
* `applicationKinds` is left out entirely, the same as a `showWhen`
* that never holds.
*/
applicationKind?: ApplicationKind;
},
): WizardStep[] {
const kind = options?.applicationKind ?? 'NEW';
const visible = [...sections]
.filter((section) => sectionAppliesToKind(section, kind))
.filter((section) => conditionHolds(section.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
@@ -511,7 +542,9 @@ export function buildWizardSteps(
...(options?.hasStaff === false
? []
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
...(options?.hasDocuments === false
? []
: [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]),
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
];
}
@@ -597,6 +630,49 @@ interface ConditionLike {
anyOf?: ConditionLike[];
}
/**
* Section keys a condition reads, recursing `anyOf`.
*
* `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
* the section whose answer decides the condition.
*/
export function conditionSections(
condition: FieldCondition | undefined | null,
): string[] {
if (!condition) return [];
if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
if (!condition.field) return [];
const [sectionKey] = condition.field.split('.');
return sectionKey ? [sectionKey] : [];
}
/**
* Sections whose visibility hangs on an answer in one of `flagged`.
*
* Mirrors the server's `sectionsDependingOn`: an officer flagging the section
* that holds the vessel category is asking for an answer that decides which
* fields in other sections are required, so those sections have to open too —
* otherwise the applicant sees newly-required fields they cannot edit and
* cannot resubmit.
*/
export function sectionsDependingOn(
sections: FormSectionConfig[],
flagged: Set<string>,
): Set<string> {
const dependent = new Set<string>();
if (flagged.size === 0) return dependent;
for (const section of sections) {
if (flagged.has(section.key)) continue;
const reads = [
section.showWhen,
...(section.fields ?? []).map((field) => field.showWhen),
].flatMap(conditionSections);
if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
}
return dependent;
}
export function conditionHolds(
condition: FieldCondition | undefined | null,
formData: Record<string, Record<string, unknown>>,

View File

@@ -25,6 +25,9 @@ export type LicenseStatus =
| "DRAFT"
| "SUBMITTED"
| "UNDER_REVIEW"
// Seafarer registration only: same slot UNDER_REVIEW occupies elsewhere,
// but approval is blocked until the applicant's profile has a BSID.
| "AWAITING_BIOMETRICS"
| "UNDER_EVALUATION"
// Employee filed their review; parked with the team leader for a decision.
| "REVIEW_REPORTED"
@@ -60,7 +63,10 @@ export type LicenseStatus =
| "EXAM_PASSED"
| "EXAM_FAILED";
export type ApplicationKind = "NEW" | "RENEWAL";
export type ApplicationKind = "NEW" | "RENEWAL" | "REISSUE";
/** Half-day window a team leader books an applicant's document pickup into. */
export type IssuancePeriod = "MORNING" | "AFTERNOON";
export type FormFieldType =
| "TEXT"
@@ -117,6 +123,12 @@ export interface FormSectionConfig {
group?: string;
/** Position of the group in the stepper; lowest value in a group wins. */
groupOrder?: number;
/**
* Restricts this section to specific application kinds — e.g. the
* Damaged/Reissue "Damage Information" step. Undefined or empty means
* every kind.
*/
applicationKinds?: ApplicationKind[];
}
/** Grouping the portal organises the licence catalogue by. */
@@ -175,6 +187,11 @@ export interface LicenseType {
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
/**
* A term in days instead of months, for licences shorter than a month can
* express. Wins over `validityMonths` when set; null keeps calendar months.
*/
validityDays?: number | null;
/**
* Target turnaround in hours. Null means this type is not tracked against
* an SLA, which the grid renders as "—" rather than as instantly overdue.
@@ -204,11 +221,39 @@ export interface LicenseType {
// --------------------------------------------------- examined certificates
/** Approval establishes eligibility; the certificate is earned by exam. */
requiresExamination?: boolean;
/** Assessment fee, due on submission before any officer review. */
feeEligibility?: string | number | null;
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
feeExamination?: string | number | null;
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
feeCertificate?: string | number | null;
// ------------------------------------------------------- behaviour config
// Editable from the backoffice Behavior tab. Optional because the API has
// only recently begun returning them and older mocks/fixtures omit them.
/** Licence or registration. Catalogue metadata; no behaviour hangs off it. */
serviceKind?: ServiceKind;
/** Platform side effect fired when an application of this type completes. */
completionEffect?: CompletionEffect | null;
/** Only ACTIVE registered seafarers may apply. */
requiresSeafarerRegistration?: boolean;
/** Submission requires a current, unexpired medical certificate. */
requiresValidMedical?: boolean;
/** Minimum VERIFIED sea time in days at submission. Null means no floor. */
minSeaTimeDays?: number | null;
/** Whether several open drafts may exist at once, for per-asset registrations. */
allowMultipleOpenDrafts?: boolean;
/** Days before expiry that renewal opens. */
renewalWindowDays?: number;
/** Days before expiry to remind the holder, most distant first. */
expiryReminderDays?: number[];
/**
* Dotted `formData` path whose answer may appear on only one live
* application of this type. Null means no such rule.
*/
uniqueFormKeyPath?: string | null;
// ---------------------------------------------------------- STCW mapping
certificateCategory?: CertificateCategory | null;
stcwControlled?: boolean;
@@ -252,7 +297,12 @@ export interface StcwCapacityRow {
export interface DocumentRequirement {
id: string;
licenseTypeId: string;
/**
* Null for a personal document — one every applicant keeps in their own
* vault regardless of what they apply for, rather than a slot on one
* licence's application form.
*/
licenseTypeId: string | null;
key: string;
name: Bilingual;
description?: Bilingual;
@@ -263,6 +313,14 @@ export interface DocumentRequirement {
maxSizeMb: number;
requiresValidityDates: boolean;
allowMultiple: boolean;
/**
* True for a personal document — one the applicant keeps in their own vault
* — rather than an upload slot on an application form. Orthogonal to
* `licenseTypeId`, which still says which licences it applies to.
*/
isPersonal: boolean;
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
maxFiles: number | null;
sortOrder: number;
isActive: boolean;
}
@@ -346,6 +404,7 @@ export interface LicenseApplication {
issuedLicenseId: string | null;
/** Set once an officer schedules pickup for a document requiring in-person handover. */
scheduledIssuanceDate: string | null;
scheduledIssuancePeriod: IssuancePeriod | null;
scheduledBy: string | null;
createdAt: string;
}
@@ -437,6 +496,13 @@ export interface ApplicationApplicant {
export interface ApplicationDetail {
application: LicenseApplication;
/**
* Current status of the license this application issued, independent of
* the application's own (permanently historical) status — a later
* reissue/renewal can supersede the license without changing what this
* application itself accomplished. Null when nothing has been issued yet.
*/
issuedLicenseStatus: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED" | null;
relatedApplications?: LicenseApplication[];
/** Null when the applicant has no profile row (never expected in practice). */
applicant: ApplicationApplicant | null;
@@ -463,6 +529,50 @@ export interface Inspection {
findings: string | null;
}
export type PickupAppointmentStatus =
| "SCHEDULED"
| "CHECKED_IN"
| "ISSUED"
| "NO_SHOW"
| "RESCHEDULED"
| "CANCELLED";
export interface PickupOffice {
id: string;
name: string;
address: string | null;
/** 0=Sunday .. 6=Saturday. */
workingDays: number[];
startTime: string;
endTime: string;
slotDurationMinutes: number;
maxApplicantsPerSlot: number;
rescheduleMinNoticeHours: number;
isActive: boolean;
}
export interface PickupSlot {
date: string;
slotStartTime: string;
capacity: number;
booked: number;
available: number;
}
export interface PickupAppointment {
id: string;
applicationId: string;
appointmentNumber: string;
officeId: string;
date: string;
slotStartTime: string;
status: PickupAppointmentStatus;
rescheduledFromId: string | null;
rescheduleCount: number;
checkedInAt: string | null;
checkedInById: string | null;
}
export interface AppNotification {
id: string;
subject: Bilingual;
@@ -479,6 +589,7 @@ export interface QueueFilter {
licenseTypeId?: string;
search?: string;
status?: LicenseStatus[];
kind?: ApplicationKind;
/** Officer uuid, or the literal 'unassigned'. */
assignee?: string;
submittedFrom?: string;
@@ -501,6 +612,15 @@ export type QueueSortField =
/** Review → evaluation → (inspection) → approval, or the short registration course. */
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
/** A permission to operate, or a registration granting a status and a number. */
export type ServiceKind = "LICENSE" | "REGISTRATION";
/** Platform side effect fired when an application reaches COMPLETED. */
export type CompletionEffect =
| "REGISTER_SEAFARER"
| "REGISTER_VESSEL"
| "OPEN_SEAFARER_DOCUMENTS";
/** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts {
unassigned: number;
@@ -744,10 +864,40 @@ export interface IssuedLicense {
* configuration.
*/
renewable?: boolean;
/** Whether a Damaged/Reissue replacement may be requested for this licence. */
reissuable?: boolean;
verificationCode: string;
certificateFileKey: string | null;
}
/**
* One personal document as the backoffice manages it: every configured row
* sharing a key, which is one slot in the applicant's vault. Several rows mean
* the document is scoped to several licence types.
*/
export interface PersonalDocumentGroup {
key: string;
rows: DocumentRequirement[];
}
export interface PersonalDocumentFilter {
/** Matches the key and the name in either locale. */
search?: string;
/** A licence type also matches the documents every licence asks for. */
licenseTypeId?: string;
/** Narrows to the documents configured against no licence type at all. */
globalOnly?: boolean;
sortBy?: 'sortOrder' | 'key' | 'name';
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
/** Which locale `sortBy: "name"` sorts on. */
locale?: 'en' | 'am';
}
// No `EligibleExam`: it typed the schedule-exam picker's options, and
// picking a sitting for a named applicant is not something the back office
// does any more — candidates register for a published sitting themselves.
/**
* Where a certificate application stands in the examination leg, derived

View File

@@ -0,0 +1,3 @@
export * from './personal-document.types';
export * from './personal-document.upload';
export * from './personal-document-api';

View File

@@ -0,0 +1,40 @@
import { baseApi } from '../../base-api';
import type { PersonalDocumentSlot } from './personal-document.types';
const TAG = 'PersonalDocument' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
/**
* The applicant's own documents — identity card, photograph, education —
* kept against their profile rather than any one application, so they survive
* having no seafarer registration yet.
*
* Reads and deletes live here; the two uploads do not. `fetch` — what
* `fetchBaseQuery` runs on — cannot report how much of a request body has gone
* up, so they use XHR instead (`personal-document.upload.ts`) and the page
* refetches this query when one finishes.
*/
export const personalDocumentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
query: () => ({ url: '/profiles/me/documents' }),
providesTags: () => [LIST],
}),
deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({
query: (fileId) => ({
url: `/profiles/me/documents/files/${fileId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
}),
overrideExisting: false,
});
export const {
useGetMyPersonalDocumentsQuery,
useDeletePersonalDocumentFileMutation,
} = personalDocumentApi;

View File

@@ -0,0 +1,22 @@
import type { AttachmentFile, Bilingual } from '../licensing/licensing.types';
/**
* One slot in the applicant's personal document vault, with whatever they
* have put in it.
*
* The slot itself is configuration: a document requirement that names no
* licence type applies to every licence, so the backoffice adds and retires
* these without a release. That is why the label, the accepted types and the
* limits arrive from the API rather than living in the portal.
*/
export interface PersonalDocumentSlot {
key: string;
name: Bilingual;
description: Bilingual | null;
/** How many files the slot holds; null means as many as the holder has. */
maxFiles: number | null;
allowedMimeTypes: string[];
maxSizeMb: number;
sortOrder: number;
files: AttachmentFile[];
}

View File

@@ -0,0 +1,116 @@
import { resolveTokenFromStorage } from '../../session';
import type { PersonalDocumentSlot } from './personal-document.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/** What the server says when it refuses a file — see ProfileDocumentsService. */
export interface PersonalDocumentError {
message?: string;
[detail: string]: unknown;
}
export type PersonalDocumentUploadResult =
| { ok: true; slot: PersonalDocumentSlot }
| { ok: false; error: PersonalDocumentError };
/**
* Uploads one file and reports how far it has got.
*
* XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no
* upload progress event, so a request body of any size is a spinner with
* nothing behind it. That is tolerable for a 5 MB scan and not for the video a
* slot can now be opened to, where the difference between "uploading" and
* "uploading, 12%" is the difference between waiting and reloading the page.
*
* The caller refetches the vault afterwards; nothing here touches the cache.
*/
function upload(
path: string,
method: 'POST' | 'PUT',
body: FormData,
onProgress?: (percent: number) => void,
): Promise<PersonalDocumentUploadResult> {
return new Promise((resolve) => {
const request = new XMLHttpRequest();
request.open(method, `${BASE_API_URL}${path}`);
const token = resolveTokenFromStorage();
if (token) request.setRequestHeader('Authorization', `Bearer ${token}`);
request.upload.onprogress = (event) => {
// Not every browser knows the total for a streamed body; without it a
// percentage would be invented, so the caller keeps its spinner.
if (!event.lengthComputable || !onProgress) return;
onProgress(Math.round((event.loaded / event.total) * 100));
};
request.onload = () => {
const parsed = parseBody(request.responseText);
if (request.status >= 200 && request.status < 300) {
resolve({ ok: true, slot: parsed as PersonalDocumentSlot });
return;
}
resolve({ ok: false, error: toError(parsed, request.status) });
};
// A dropped connection and a cancelled request both land here; neither
// carries a server message, so the caller falls back to its own wording.
request.onerror = () => resolve({ ok: false, error: {} });
request.onabort = () => resolve({ ok: false, error: {} });
request.send(body);
});
}
function parseBody(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Nest wraps a thrown `BadRequestException({ message, ... })` as
* `{ message: { message, ... } }`, and a plain string message as
* `{ message: "slot_full" }`. Both are flattened to the object the UI
* translates by its `message` key.
*/
function toError(parsed: unknown, status: number): PersonalDocumentError {
const message = (parsed as { message?: unknown } | null)?.message;
if (typeof message === 'object' && message !== null) {
return message as PersonalDocumentError;
}
if (typeof message === 'string') return { message };
return { message: `http_${status}` };
}
/** Adds one file to a personal document slot. */
export function uploadPersonalDocumentFile(params: {
documentKey: string;
file: File;
onProgress?: (percent: number) => void;
}): Promise<PersonalDocumentUploadResult> {
const body = new FormData();
body.append('documentKey', params.documentKey);
body.append('file', params.file, params.file.name);
return upload('/profiles/me/documents', 'POST', body, params.onProgress);
}
/** Swaps one file for another in the same slot. */
export function replacePersonalDocumentFile(params: {
fileId: string;
file: File;
onProgress?: (percent: number) => void;
}): Promise<PersonalDocumentUploadResult> {
const body = new FormData();
body.append('file', params.file, params.file.name);
return upload(
`/profiles/me/documents/files/${params.fileId}`,
'PUT',
body,
params.onProgress,
);
}

View File

@@ -4,6 +4,7 @@ import type {
SeafarerDocument,
SeafarerDocumentDetail,
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentRow,
SeafarerDocumentStatus,
} from './seafarer-document.types';
@@ -14,6 +15,7 @@ const item = (id: string) => ({ type: TAG, id }) as const;
export interface SeafarerDocumentListFilter {
kind?: SeafarerDocumentKind;
requestKind?: SeafarerDocumentRequestKind;
status?: SeafarerDocumentStatus;
search?: string;
take?: number;
@@ -63,6 +65,16 @@ export const seafarerDocumentApi = baseApi
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
renewSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/renew`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
replaceSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/replace`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
// --------------------------------------------------------------- review
listSeafarerDocuments: builder.query<
{ total: number; items: SeafarerDocumentRow[] },
@@ -121,6 +133,8 @@ export const {
useInitiateDocumentPaymentMutation,
useGetDocumentPaymentQuery,
useBypassDocumentPaymentMutation,
useRenewSeafarerDocumentMutation,
useReplaceSeafarerDocumentMutation,
useListSeafarerDocumentsQuery,
useGetSeafarerDocumentReviewQuery,
useLazyGetSeafarerDocumentReviewDownloadQuery,

View File

@@ -1,10 +1,26 @@
import type { SeafarerDocumentKind, SeafarerDocumentStatus } from './seafarer-document.types';
import type {
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentStatus,
} from './seafarer-document.types';
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_LABELS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'New',
RENEWAL: 'Renewal',
REPLACEMENT: 'Replacement',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_COLORS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'gray',
RENEWAL: 'blue',
REPLACEMENT: 'orange',
};
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
AWAITING_REGISTRATION: 'Awaiting Registration',
PAYMENT_PENDING: 'Payment Pending',

View File

@@ -12,14 +12,19 @@ export type SeafarerDocumentStatus =
| 'REJECTED'
| 'CANCELLED';
/** A Seaman Book or BTC request — opened by a seafarer registration. */
/** NEW comes from a seafarer registration; RENEWAL/REPLACEMENT are applicant-initiated. */
export type SeafarerDocumentRequestKind = 'NEW' | 'RENEWAL' | 'REPLACEMENT';
/** A Seaman Book or BTC request — opened by a seafarer registration, or by the applicant as a renewal/replacement. */
export interface SeafarerDocument {
id: string;
kind: SeafarerDocumentKind;
requestKind: SeafarerDocumentRequestKind;
requestNumber: string;
applicantUserId: string;
profileId: string | null;
seafarerRegistrationId: string | null;
previousDocumentId: string | null;
status: SeafarerDocumentStatus;
feeAmount: number | null;
feeCurrency: string;

View File

@@ -10,9 +10,17 @@ const TAG = 'SeafarerRegistration' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
const item = (id: string) => ({ type: TAG, id }) as const;
export type SeafarerRegistrationSortField =
| 'submittedAt'
| 'registrationNumber'
| 'lastName'
| 'status';
export interface SeafarerRegistrationListFilter {
status?: SeafarerRegistrationStatus;
search?: string;
sortBy?: SeafarerRegistrationSortField;
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
}

View File

@@ -25,6 +25,19 @@ export const DEPARTMENT_OPTIONS = [
{ value: 'CATERING', label: 'Catering' },
];
/**
* The STCW limitation a Certificate of Competency is issued under.
*
* One choice, worded generically, because the threshold it means differs by
* department — gross tonnage on deck, propulsion power in the engine room. The
* certificate states the department-specific wording; the applicant only picks
* which side of the line they serve.
*/
export const RANK_TIER_OPTIONS = [
{ value: 'ABOVE', label: 'Above' },
{ value: 'BELOW', label: 'Below' },
];
export const HAIR_COLOR_OPTIONS = [
{ value: 'BLACK', label: 'Black' },
{ value: 'BROWN', label: 'Brown' },
@@ -117,6 +130,8 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_REVIEW: 'Under Review',
RESUBMIT_REQUIRED: 'Corrections Requested',
APPROVED: 'Approved',
REJECTED: 'Rejected',
@@ -139,6 +154,8 @@ export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
> = {
DRAFT: 'neutral',
SUBMITTED: 'info',
AWAITING_BIOMETRICS: 'pending',
UNDER_REVIEW: 'info',
RESUBMIT_REQUIRED: 'pending',
APPROVED: 'success',
REJECTED: 'danger',
@@ -158,6 +175,7 @@ export const SEAFARER_REGISTRATION_FIELD_LABELS: Record<keyof SeafarerRegistrati
passportNumber: 'Passport Number',
passportExpiry: 'Passport Expiry Date',
department: 'Department',
tier: 'Certificate Limitation',
locationId: 'Location',
permanentAddress: 'Permanent Address',
currentAddress: 'Current Address',
@@ -192,7 +210,7 @@ export const SEAFARER_REGISTRATION_SECTIONS: {
{
key: 'identity',
title: 'Identity',
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department', 'tier'],
},
{
key: 'address',
@@ -220,6 +238,7 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
gender: GENDER_OPTIONS,
maritalStatus: MARITAL_STATUS_OPTIONS,
department: DEPARTMENT_OPTIONS,
tier: RANK_TIER_OPTIONS,
hairColor: HAIR_COLOR_OPTIONS,
eyeColor: EYE_COLOR_OPTIONS,
bloodType: BLOOD_TYPE_OPTIONS,

View File

@@ -1,7 +1,16 @@
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
/** Above/Below — the STCW limitation a Certificate of Competency is issued under. */
export type RankTier = 'ABOVE' | 'BELOW';
export type SeafarerRegistrationStatus =
| 'DRAFT'
// Filed, waiting on the counter-side biometric capture that produces the
// BSID approval is blocked on.
| 'AWAITING_BIOMETRICS'
// BSID issued — the file is a reviewer's to decide.
| 'UNDER_REVIEW'
// Retained for registrations filed before biometrics moved ahead of review.
| 'SUBMITTED'
| 'RESUBMIT_REQUIRED'
| 'APPROVED'
@@ -30,6 +39,11 @@ export interface SeafarerRegistrationAnswers {
passportNumber: string | null;
passportExpiry: string | null;
department: SeafarerDepartment | null;
/**
* The STCW ship-size limitation this seafarer's CoCs are issued under.
* Read with `department` to pick the CoC ladder; CoP carries no tier.
*/
tier: RankTier | null;
locationId: string | null;
permanentAddress: string | null;
currentAddress: string | null;

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import Cookies from "js-cookie";
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
currentPositionId: 'x-current-position-id',
currentProjectId: 'x-current-project-id',
tenantId: "x-tenant-id",
organizationUnitId: "x-organization-unit-id",
currentPositionId: "x-current-position-id",
currentProjectId: "x-current-project-id",
} as const;
/**
* Which app this bundle is, so it reads its own session and no one else's.
*
* Set by each app's store via `configureSessionScope`. Cookies ignore the
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
* port, so `localhost:3000` and `localhost:4201` share one jar: without a
* scope the backoffice would happily authenticate as whoever last signed into
* the portal, and render a staff console with an applicant's permissions.
*/
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
}
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
const LEGACY_TOKEN_KEY = 'auth-token';
const LEGACY_TOKEN_KEY = "auth-token";
export function resolveTokenFromStorage(): string | undefined {
// Only this app's key, then the legacy unprefixed one. Never another app's:

View File

@@ -6,6 +6,7 @@ export { AuthBootstrap } from "./lib/components/AuthBootstrap";
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
export { LoginPage } from "./lib/pages/LoginPage";
export { SignupPage } from "./lib/pages/SignupPage";
export { FaydaCallbackPage } from "./lib/pages/FaydaCallbackPage";
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
export { SetPasswordPage } from "./lib/pages/SetPasswordPage";
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";

View File

@@ -6,9 +6,7 @@ import { hydrateAuth, logout, setToken, setUser } from '../store/auth.slice';
import { refreshAccessToken } from '../utils/refresh-token';
import type { AuthUser } from '../types/auth.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL } from '@ema-platform/api';
/**
* Restores the signed-in session before the router renders.

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Group, Loader, Stack, Text, Title } from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
/**
* Where Fayda returns the applicant.
*
* It creates no account and holds no credentials — it hands the authorization
* code to the API, stashes the normalised result, and sends the applicant back
* to the signup form they started on.
*/
export function FaydaCallbackPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const [params] = useSearchParams();
const { handleError } = useErrorHandler();
const [error, setError] = useState<string | null>(null);
const [callbackTrigger] = useApiMutation<FaydaResult>();
// React 18 mounts effects twice in development, and the authorization code is
// single-use — the second redemption would fail and show a spurious error.
const redeemed = useRef(false);
useEffect(() => {
if (redeemed.current) return;
redeemed.current = true;
const code = params.get('code');
const state = params.get('state');
const providerError = params.get('error');
const request = faydaSession.takeRequest();
if (providerError) {
setError(
providerError === 'access_denied'
? t('fayda.cancelled', 'Fayda verification was cancelled. You can still sign up manually.')
: t('fayda.rejected', 'Fayda could not verify your identity. Please try again.'),
);
return;
}
if (!code || !state) {
setError(t('fayda.invalidCallback', 'This verification link is incomplete. Please start again.'));
return;
}
if (!request) {
setError(
t('fayda.sessionLost', 'Your verification session has expired. Please start again.'),
);
return;
}
if (request.state !== state) {
setError(t('fayda.stateMismatch', 'This verification could not be trusted. Please start again.'));
return;
}
callbackTrigger({
url: '/auth/register-with-fayda',
method: 'POST',
// `verify` returns the identity without creating an account — the
// existing signup endpoint still does that.
body: { action: 'verify', code, state, transactionToken: request.transactionToken },
})
.unwrap()
.then((result) => {
faydaSession.saveResult(result);
// replace: the callback URL carries a spent code, so it must not come
// back on Back.
navigate('/signup', { replace: true });
})
.catch((err: unknown) => setError(handleError(err)));
// Runs once on mount; the guard above makes that explicit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<AuthShell
brandTitle={t('fayda.brandTitle', 'Verifying with Fayda')}
brandSubtitle={t('fayda.brandSubtitle', 'One moment while we confirm your identity.')}
>
<Stack gap="lg">
{error ? (
<>
<Title order={2} fz={26}>
{t('fayda.failedTitle', 'Verification incomplete')}
</Title>
<Alert
variant="light"
color="orange"
icon={<IconAlertTriangle size={18} />}
>
{error}
</Alert>
<Group>
<Button
variant="light"
leftSection={<IconArrowLeft size={18} />}
onClick={() => navigate('/signup', { replace: true })}
>
{t('fayda.backToSignup', 'Back to sign up')}
</Button>
</Group>
</>
) : (
<Group gap="sm">
<Loader size="sm" />
<Text c="dimmed">{t('fayda.verifying', 'Verifying your Fayda identity…')}</Text>
</Group>
)}
</Stack>
</AuthShell>
);
}

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Alert,
Anchor,
Badge,
Button,
Checkbox,
Group,
Divider,
PasswordInput,
SimpleGrid,
Stack,
@@ -14,11 +15,14 @@ import {
UnstyledButton,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconAt,
IconId,
IconLock,
IconMail,
IconRosetteDiscountCheck,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
@@ -33,6 +37,7 @@ import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
interface SignupPayload {
email: string;
@@ -62,6 +67,56 @@ export function SignupPage() {
}>();
const [meTrigger] = useApiMutation<AuthUser>();
// Fayda is optional: the form below works exactly as before without it.
const [fayda, setFayda] = useState<FaydaResult | null>(() => faydaSession.peekResult());
const [faydaStarting, setFaydaStarting] = useState(false);
const [startTrigger] = useApiMutation<{
authorizationUrl: string;
state: string;
transactionToken: string;
expiresIn: number;
}>();
const [linkTrigger] = useApiMutation<{ phoneNumberVerified?: boolean }>();
const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false;
const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false;
/**
* Per-field provenance, so it is obvious which values came from Fayda and
* which are still the applicant's to supply. Verified fields stay editable —
* a conflicting email has to be changeable for the form to be completable at
* all.
*/
const faydaMark = (field: string): { description?: React.ReactNode } => {
if (conflicted(field)) {
return {
// component="span" on these badges: the description slot renders
// inside a <p>, where Badge's default <div> is invalid HTML.
description: (
<Badge component="span" size="xs" variant="light" color="orange">
{t('fayda.fieldConflict', 'Already used by another account')}
</Badge>
),
};
}
if (verified(field)) {
return {
description: (
<Badge
component="span"
size="xs"
variant="light"
color="teal"
leftSection={<IconRosetteDiscountCheck size={11} />}
>
{t('fayda.fieldVerified', 'From Fayda')}
</Badge>
),
};
}
return {};
};
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
@@ -118,6 +173,42 @@ export function SignupPage() {
defaultValues: { userType: 'individual' },
});
// Fills what Fayda vouched for and leaves the rest — username and password
// are always the applicant's to choose, and Fayda supplies neither.
useEffect(() => {
if (!fayda) return;
const { email, phoneNumber: phone, nameEn, nameAm } = fayda.identity;
if (email) setValue('email', email);
if (phone) setValue('phoneNumber', phone);
if (nameEn) setValue('nameEn', nameEn);
if (nameAm) setValue('nameAm', nameAm);
}, [fayda, setValue]);
const startFayda = async () => {
setServerError(null);
setFaydaStarting(true);
try {
// Same endpoint the registration itself uses; `start` only opens the
// attempt and hands back where to send the user.
const { authorizationUrl, transactionToken, state } = await startTrigger({
url: '/auth/register-with-fayda',
method: 'POST',
body: { action: 'start' },
}).unwrap();
faydaSession.saveRequest({ transactionToken, state });
window.location.assign(authorizationUrl);
} catch (err: unknown) {
setFaydaStarting(false);
setServerError(handleError(err));
}
};
const clearFayda = () => {
faydaSession.clearResult();
setFayda(null);
};
const onSubmit = async (values: FormValues) => {
try {
const payload: SignupPayload = {
@@ -147,7 +238,28 @@ export function SignupPage() {
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
// Records the Fayda-verified identity on the new account: marks the
// phone verified when it is the one Fayda vouched for, and fills the
// still-empty profile fields. Best-effort — the account already works,
// and the token can be presented again on a retry.
let faydaPhoneVerified = false;
if (fayda?.verificationToken) {
try {
const applied = await linkTrigger({
url: '/profiles/me/fayda',
method: 'POST',
body: { verificationToken: fayda.verificationToken },
}).unwrap();
faydaPhoneVerified = Boolean(applied?.phoneNumberVerified);
} catch {
/* deliberately ignored — signup already succeeded */
}
}
faydaSession.clearResult();
// Fayda already verified this exact number via its own OTP; asking for
// a second OTP on the same number is theatre.
if (data.isPhoneNumberVerified || faydaPhoneVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
@@ -212,6 +324,53 @@ export function SignupPage() {
</Alert>
)}
{fayda ? (
<Alert
variant="light"
color="teal"
icon={<IconRosetteDiscountCheck size={18} />}
title={t('fayda.verifiedTitle', 'Verified with Fayda')}
>
<Stack gap="xs">
<Text size="sm">
{t(
'fayda.verifiedBody',
'We filled in the details Fayda confirmed. Please complete the remaining fields.',
)}
</Text>
<Anchor size="sm" component="button" type="button" onClick={clearFayda}>
{t('fayda.discard', 'Clear these details and fill the form myself')}
</Anchor>
</Stack>
</Alert>
) : (
<>
<Button
variant="default"
size="md"
fullWidth
loading={faydaStarting}
leftSection={<IconId size={18} />}
onClick={startFayda}
>
{t('fayda.continueWith', 'Continue with Fayda')}
</Button>
<Divider
label={t('fayda.orFillManually', 'or fill in your details')}
labelPosition="center"
/>
</>
)}
{fayda && fayda.conflicts.length > 0 && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={18} />}>
{t(
'fayda.conflictBody',
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
)}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
@@ -220,6 +379,7 @@ export function SignupPage() {
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
leftSection={<IconUser size={18} />}
error={errors.nameEn?.message}
{...faydaMark('nameEn')}
{...register('nameEn')}
/>
<TextInput
@@ -227,6 +387,7 @@ export function SignupPage() {
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...faydaMark('nameAm')}
{...register('nameAm')}
/>
</SimpleGrid>
@@ -237,6 +398,7 @@ export function SignupPage() {
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...faydaMark('email')}
{...register('email')}
/>
<TextInput
@@ -255,6 +417,7 @@ export function SignupPage() {
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
onBlur={() => trigger('phoneNumber')}
error={errors.phoneNumber?.message}
{...faydaMark('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">

View File

@@ -47,6 +47,8 @@ export const LICENSE_PERMISSIONS = {
MANAGE_SEAFARER_STATUS: "can:manage:seafarer-status",
VIEW_SEAFARER_REGISTRY: "can:View:seafarer-registry",
VERIFY_SEAFARER_RECORDS: "can:verify:seafarer-records",
ENROLL_BIOMETRICS: "can:enroll:biometrics",
VIEW_BIOMETRICS: "can:View:biometrics",
VIEW_VESSEL_REGISTRY: "can:View:vessel-registry",
MANAGE_VESSEL_STATUS: "can:manage:vessel-status",
APPROVE_QUESTION: "can:approve:exam-question",
@@ -81,6 +83,7 @@ export const PORTAL_PERMISSIONS = {
APPLY_EXAM: "can:apply:exam",
VIEW_OWN_EXAM: "can:View:own-exam",
VIEW_OWN_CERTIFICATES: "can:View:own-certificates",
VIEW_OWN_BIOMETRICS: "can:View:own-biometrics",
APPLY_VESSEL_REGISTRATION: "can:apply:vessel-registration",
VIEW_OWN_VESSELS: "can:View:own-vessels",
REPORT_VESSEL_INCIDENT: "can:report:own-vessel-incident",

View File

@@ -84,6 +84,8 @@ export interface CurrentProfile {
* registration is approved, null before that.
*/
seafarerNumber?: string | null;
/** Biometric Subject ID — stamped by staff once biometric enrollment is confirmed. */
bsid?: string | null;
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;

View File

@@ -0,0 +1,87 @@
/**
* The Fayda round trip leaves the app entirely, so the little state that has to
* survive it lives in sessionStorage: same tab, same origin, gone when the tab
* closes.
*
* Nothing secret is kept here. The `transactionToken` is signed by the API and
* useless without it — the PKCE verifier, the nonce and the client key never
* leave the backend.
*/
const REQUEST_KEY = 'fayda:request';
const RESULT_KEY = 'fayda:result';
export interface FaydaRequest {
transactionToken: string;
state: string;
}
export interface FaydaPrefill {
email?: string;
phoneNumber?: string;
nameEn?: string;
nameAm?: string;
/** Shown for context only — the signup form has no field for these. */
gender?: string;
address?: string;
birthdate?: string;
nationality?: string;
faydaNumber?: string;
}
/** Shape of `POST /auth/register-with-fayda` with `action: "verify"`. */
export interface FaydaResult {
identity: FaydaPrefill;
faydaVerified: boolean;
/** Signup fields Fayda vouched for. */
verifiedFields: string[];
/** Prefilled fields already taken by another account. */
conflicts: string[];
/**
* Encrypted proof of the verification, presented to POST /profiles/me/fayda
* after signup so the account and profile record what Fayda vouched for.
*/
verificationToken: string;
}
// Private browsing and locked-down browsers can throw on access, and a failure
// here should degrade to "no Fayda prefill", never break the signup page.
function read<T>(key: string): T | null {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function write(key: string, value: unknown): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* nothing to do — the flow reports a generic failure instead */
}
}
function clear(key: string): void {
try {
sessionStorage.removeItem(key);
} catch {
/* ignore */
}
}
export const faydaSession = {
saveRequest: (request: FaydaRequest) => write(REQUEST_KEY, request),
takeRequest: (): FaydaRequest | null => {
const request = read<FaydaRequest>(REQUEST_KEY);
// Single use: a stale token would otherwise be replayed against a fresh
// callback and fail with a confusing "session expired".
clear(REQUEST_KEY);
return request;
},
saveResult: (result: FaydaResult) => write(RESULT_KEY, result),
peekResult: (): FaydaResult | null => read<FaydaResult>(RESULT_KEY),
clearResult: () => clear(RESULT_KEY),
};

View File

@@ -1,9 +1,6 @@
import { authStorage } from "./auth-storage";
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
import { BASE_API_URL } from "@ema-platform/api";
interface RefreshResponse {
token: string;

View File

@@ -2,6 +2,7 @@ export * from "./lib/input/BilingualInput";
export * from "./lib/input/AmharicDatePicker";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/PdfPreviewModal";
export * from "./lib/feedback/FilePreviewModal";
export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";
@@ -24,6 +25,8 @@ export * from "./lib/layout/SkipLink";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/canvas-point";
export * from "./lib/components/SignaturePad";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";

View File

@@ -0,0 +1,316 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
Box,
Button,
Group,
Image,
Loader,
Paper,
SegmentedControl,
Stack,
Text,
Title,
} from '@mantine/core';
import {
IconInfoCircle,
IconPencil,
IconTrash,
IconUpload,
IconWriting,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify } from '../feedback/notify';
import { useErrorHandler } from '../feedback/use-error-handler';
import { toCanvasPoint } from '../input/canvas-point';
/** Mirrors the API's own limits (`ProfileService.saveSignature`). */
const ACCEPTED = ['image/png', 'image/jpeg'];
const MAX_BYTES = 2 * 1024 * 1024;
/**
* The drawing surface's backing-store size.
*
* Fixed rather than matched to the rendered element: this is what gets printed
* on a Seaman Book, so the stored image must not vary with the width of the
* browser window it happened to be drawn in. The canvas is displayed at
* whatever width the layout gives it and scaled to these dimensions.
*/
const PAD_WIDTH = 800;
const PAD_HEIGHT = 260;
/**
* Captures a specimen signature, drawn or uploaded.
*
* Drawing is a plain canvas with pointer events — one element and ~40 lines,
* where a signature-pad dependency would be a package to keep patched. Pointer
* events (not mouse + touch separately) cover mouse, finger and stylus in one
* set of handlers.
*/
export interface SignaturePadProps {
/** Short-lived link to the signature on file, or null when there is none. */
currentUrl: string | null;
isLoading: boolean;
isUploading: boolean;
isDeleting: boolean;
onUpload: (file: File) => Promise<unknown>;
onDelete: () => Promise<unknown>;
}
export function SignaturePad({
currentUrl,
isLoading,
isUploading,
isDeleting,
onUpload,
onDelete,
}: SignaturePadProps) {
const { t } = useTranslation();
const { handleError } = useErrorHandler();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const drawing = useRef(false);
// Whether anything has actually been drawn — a blank canvas still encodes to
// a valid PNG, so without this "Save" would happily store an empty image.
const [hasInk, setHasInk] = useState(false);
const [mode, setMode] = useState<'draw' | 'upload'>('draw');
const busy = isUploading || isDeleting;
const context = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!ctx) return null;
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#111';
return ctx;
}, []);
// The stored signature is flattened onto white before upload, so a canvas
// left transparent would print as a black box on some renderers.
const clear = useCallback(() => {
const ctx = context();
if (!ctx) return;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, PAD_WIDTH, PAD_HEIGHT);
setHasInk(false);
}, [context]);
useEffect(() => {
if (mode === 'draw') clear();
}, [mode, clear]);
const pointAt = (event: React.PointerEvent<HTMLCanvasElement>) =>
toCanvasPoint(
event.clientX,
event.clientY,
event.currentTarget.getBoundingClientRect(),
{ width: PAD_WIDTH, height: PAD_HEIGHT },
);
const onPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
const ctx = context();
if (!ctx) return;
// Keeps strokes tracking the pointer when it leaves the canvas mid-signature
// rather than ending the line at the edge.
event.currentTarget.setPointerCapture(event.pointerId);
drawing.current = true;
const { x, y } = pointAt(event);
ctx.beginPath();
ctx.moveTo(x, y);
// A tap with no movement should still leave a mark (a dot on an "i").
ctx.lineTo(x, y);
ctx.stroke();
setHasInk(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
if (!drawing.current) return;
const ctx = context();
if (!ctx) return;
const { x, y } = pointAt(event);
ctx.lineTo(x, y);
ctx.stroke();
};
const onPointerUp = () => {
drawing.current = false;
};
const save = async (file: File) => {
try {
await onUpload(file);
notify.success(t('profile.signature.saved'));
if (mode === 'draw') clear();
} catch (error) {
handleError(error);
}
};
const saveDrawing = () => {
const canvas = canvasRef.current;
if (!canvas || !hasInk) return;
canvas.toBlob((blob) => {
if (!blob) {
notify.error(t('profile.signature.drawFailed'));
return;
}
void save(new File([blob], 'signature.png', { type: 'image/png' }));
}, 'image/png');
};
// Validated here as well as server-side so the reason is immediate and the
// user is not made to wait on an upload that is going to be rejected.
const onFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
// Lets the same file be picked again after a rejection.
event.target.value = '';
if (!file) return;
if (!ACCEPTED.includes(file.type)) {
notify.error(t('profile.signature.badType'));
return;
}
if (file.size > MAX_BYTES) {
notify.error(t('profile.signature.tooLarge'));
return;
}
void save(file);
};
const handleDelete = async () => {
try {
await onDelete();
notify.success(t('profile.signature.removed'));
} catch (error) {
handleError(error);
}
};
return (
<Stack gap="lg">
<div>
<Title order={5}>{t('profile.signature.title')}</Title>
<Text size="sm" c="dimmed">
{t('profile.signature.description')}
</Text>
</div>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t('profile.signature.reissueNotice')}
</Alert>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : currentUrl ? (
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Text size="sm" fw={500}>
{t('profile.signature.current')}
</Text>
<Image
src={currentUrl}
alt={t('profile.signature.currentAlt')}
fit="contain"
h={120}
bg="white"
/>
<Group>
<Button
variant="light"
color="red"
size="xs"
leftSection={<IconTrash size={16} />}
onClick={handleDelete}
loading={isDeleting}
>
{t('profile.signature.remove')}
</Button>
</Group>
</Stack>
</Paper>
) : (
<Text size="sm" c="dimmed">
{t('profile.signature.none')}
</Text>
)}
<SegmentedControl
value={mode}
onChange={(value) => setMode(value as 'draw' | 'upload')}
data={[
{ value: 'draw', label: t('profile.signature.modeDraw') },
{ value: 'upload', label: t('profile.signature.modeUpload') },
]}
/>
{mode === 'draw' ? (
<Stack gap="sm">
<Box
component="canvas"
ref={canvasRef}
width={PAD_WIDTH}
height={PAD_HEIGHT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{
width: '100%',
height: 'auto',
aspectRatio: `${PAD_WIDTH} / ${PAD_HEIGHT}`,
border: '1px dashed var(--mantine-color-gray-4)',
borderRadius: 'var(--mantine-radius-md)',
background: '#fff',
// Stops the browser panning/zooming the page mid-stroke on touch.
touchAction: 'none',
cursor: 'crosshair',
}}
/>
<Group>
<Button
leftSection={<IconWriting size={16} />}
onClick={saveDrawing}
loading={isUploading}
disabled={!hasInk || busy}
>
{t('profile.signature.save')}
</Button>
<Button
variant="default"
leftSection={<IconPencil size={16} />}
onClick={clear}
disabled={!hasInk || busy}
>
{t('profile.signature.clear')}
</Button>
</Group>
</Stack>
) : (
<Stack gap="sm">
<Button
component="label"
variant="light"
leftSection={<IconUpload size={16} />}
loading={isUploading}
disabled={busy}
style={{ alignSelf: 'flex-start' }}
>
{t('profile.signature.choose')}
<input
type="file"
hidden
accept={ACCEPTED.join(',')}
onChange={onFile}
/>
</Button>
<Text size="xs" c="dimmed">
{t('profile.signature.fileHint')}
</Text>
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,169 @@
import { Anchor, Button, Group, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconExternalLink, IconFileUnknown } from '@tabler/icons-react';
/** How a file is shown, once its type is known. */
type PreviewKind = 'image' | 'video' | 'audio' | 'embed' | 'unsupported';
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg'];
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a'];
const EMBED_EXTENSIONS = ['pdf', 'txt', 'csv', 'json', 'xml'];
/**
* What the browser can actually render, decided from the mime type where there
* is one and the URL's extension where there is not.
*
* Presigned links carry the storage key in the path, so the extension survives
* even when the caller only has a URL. `image/tiff` and `image/heic` are
* deliberately treated as images: Safari renders both, and everywhere else the
* `<img>` fails visibly rather than an iframe offering a silent download.
*/
export function resolvePreviewKind(url: string, mimeType?: string | null): PreviewKind {
const mime = mimeType?.toLowerCase() ?? '';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime === 'application/pdf' || mime.startsWith('text/')) return 'embed';
// Word, Excel and the rest: nothing renders them inline, and an iframe would
// quietly start a download instead of previewing anything.
if (mime) return 'unsupported';
const extension = extensionOf(url);
if (!extension) return 'embed';
if (IMAGE_EXTENSIONS.includes(extension)) return 'image';
if (VIDEO_EXTENSIONS.includes(extension)) return 'video';
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio';
if (EMBED_EXTENSIONS.includes(extension)) return 'embed';
return 'unsupported';
}
function extensionOf(url: string): string | null {
// Presigned URLs carry a query string; the path is the part with the name.
const path = url.split(/[?#]/)[0];
const name = path.slice(path.lastIndexOf('/') + 1);
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
}
/**
* The one place a stored file gets opened anywhere in the app.
*
* Never `window.open` / `target="_blank"` a file that can be shown here —
* route it through this modal so the reviewer never loses their place to a new
* tab. What a slot accepts is configuration now, so this had to grow past the
* PDF it started as: a national ID arrives as a photograph, evidence arrives
* as video, and an academic record sometimes arrives as the Word file its
* institution issued. The last of those genuinely cannot be rendered by a
* browser, so it gets an honest panel and a link out rather than an iframe
* that silently downloads it.
*/
export function FilePreviewModal({
opened,
onClose,
url,
title = 'Document',
mimeType,
/** Overrides the detected kind — for a blob URL with no extension. */
kind,
labels,
}: {
opened: boolean;
onClose: () => void;
url: string;
title?: string;
mimeType?: string | null;
kind?: PreviewKind;
/** Supplied by the app so this stays out of the i18n bundles. */
labels?: { unsupported?: string; openInNewTab?: string; close?: string };
}) {
const resolved = kind ?? resolvePreviewKind(url, mimeType);
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
size="80%"
centered
trapFocus
returnFocus
styles={
resolved === 'image' || resolved === 'video'
? // A photograph on a white sheet loses its own edges; the dark mat
// is what tells the eye where the file ends.
{ body: { background: 'var(--mantine-color-dark-8)', padding: 0 } }
: undefined
}
>
{url && resolved === 'image' && (
<img
src={url}
alt={title}
style={{
display: 'block',
margin: '0 auto',
maxWidth: '100%',
maxHeight: '85vh',
objectFit: 'contain',
}}
/>
)}
{url && resolved === 'video' && (
// Controls only, no autoplay: a review screen that starts making noise
// on open is a review screen people mute and then miss the audio on.
<video
src={url}
controls
preload="metadata"
style={{ display: 'block', width: '100%', maxHeight: '85vh' }}
>
<track kind="captions" />
</video>
)}
{url && resolved === 'audio' && (
<Stack p="md">
<audio src={url} controls style={{ width: '100%' }}>
<track kind="captions" />
</audio>
</Stack>
)}
{url && resolved === 'embed' && (
<iframe
src={url}
title={title}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
)}
{url && resolved === 'unsupported' && (
<Stack align="center" gap="sm" py="xl">
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
<IconFileUnknown size={28} stroke={1.5} />
</ThemeIcon>
<Text fz="sm" c="dimmed" ta="center" maw={420}>
{labels?.unsupported ??
'This file type cannot be shown here. Open it in a new tab to download it.'}
</Text>
<Group>
<Button
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
variant="light"
leftSection={<IconExternalLink size={15} />}
>
{labels?.openInNewTab ?? 'Open in a new tab'}
</Button>
<Anchor component="button" type="button" fz="sm" onClick={onClose}>
{labels?.close ?? 'Close'}
</Anchor>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { Modal } from '@mantine/core';
import { FilePreviewModal } from './FilePreviewModal';
interface PdfPreviewModalProps {
opened: boolean;
@@ -8,9 +8,14 @@ interface PdfPreviewModalProps {
}
/**
* The one place a PDF gets opened anywhere in the app. Never `window.open` /
* `target="_blank"` a PDF directly — route it through this modal instead, so
* the reviewer never loses their place to a new tab.
* A PDF viewer, kept as its own name because most callers only ever open a
* PDF and say so at the call site.
*
* The rendering lives in {@link FilePreviewModal}, which also handles images,
* video and the file types no browser can show. Callers that know the mime
* type should use that directly; the ones here pass a URL alone and get the
* same iframe they always had, since a link with no `.something` on the end
* resolves to the embed view.
*/
export function PdfPreviewModal({
opened,
@@ -19,22 +24,6 @@ export function PdfPreviewModal({
title = 'Document',
}: PdfPreviewModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title={title}
size="80%"
centered
trapFocus
returnFocus
>
{url && (
<iframe
src={url}
title={title}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
)}
</Modal>
<FilePreviewModal opened={opened} onClose={onClose} url={url} title={title} />
);
}

View File

@@ -0,0 +1,30 @@
import { toCanvasPoint } from './canvas-point';
/**
* Guards the scaling between a canvas's on-screen size and its backing store.
* Getting this wrong offsets strokes from the cursor — worse the further from
* the origin — which stays invisible until someone actually tries to sign.
*/
describe('toCanvasPoint', () => {
const size = { width: 800, height: 260 };
// Half scale: 400px wide on screen, 800 in the backing store.
const rect = { left: 100, top: 50, width: 400, height: 130 };
it('maps the top-left corner to the origin', () => {
expect(toCanvasPoint(100, 50, rect, size)).toEqual({ x: 0, y: 0 });
});
it('maps the bottom-right corner to the full backing-store size', () => {
expect(toCanvasPoint(500, 180, rect, size)).toEqual({ x: 800, y: 260 });
});
it('scales a midpoint rather than using raw client pixels', () => {
// Raw offset would be (200, 65) — half of the correct answer.
expect(toCanvasPoint(300, 115, rect, size)).toEqual({ x: 400, y: 130 });
});
it('is unscaled when the element is already the backing-store size', () => {
const exact = { left: 0, top: 0, width: 800, height: 260 };
expect(toCanvasPoint(123, 45, exact, size)).toEqual({ x: 123, y: 45 });
});
});

View File

@@ -0,0 +1,20 @@
/**
* Pointer position in canvas coordinates.
*
* A canvas is displayed at whatever width the layout gives it, but drawn into a
* fixed backing store, so a click at the right-hand edge of a 400px-wide
* element has to land at x=width, not x=400. Skipping this scaling is the
* classic canvas bug: strokes appear offset from the cursor, worsening the
* further from the origin you draw.
*/
export function toCanvasPoint(
clientX: number,
clientY: number,
rect: { left: number; top: number; width: number; height: number },
size: { width: number; height: number },
) {
return {
x: ((clientX - rect.left) / rect.width) * size.width,
y: ((clientY - rect.top) / rect.height) * size.height,
};
}