Files
emaui/apps/portal/src/app/features/onboarding/components/RequireOperations.tsx
2026-08-17 07:52:09 +00:00

101 lines
3.7 KiB
TypeScript

import { Navigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
import { PageLoader } from '@ema-platform/ui';
/**
* Sends an applicant who has not said what they operate as to the step that
* asks. The logistics licence catalogue is keyed off that answer — it offers
* nothing without it, and the server refuses an application for a mode the
* profile does not hold — so it is asked once, up front.
*
* The gate is deliberately not portal-wide. Seafarer and vessel services are
* seeded `requiresOperatorMode: false` precisely because a seafarer holds no
* logistics mode of operation; gating them on this question locked the people
* those services exist for out of registration, sea records, certificates and
* examinations. Profile and support stay open for the same reason: someone who
* cannot answer the question yet must still reach their account and ask for
* help.
*/
const ALWAYS_ALLOWED = [
'/onboarding/operations',
'/profile',
'/support',
'/notifications',
// Seafarer services — no operator mode required by the licence types behind
// them (SEAFARER_REGISTRATION, CERTIFICATE_OF_COMPETENCY/PROFICIENCY).
'/seafarer-registration',
'/seafarer/records',
'/seafarer-registry',
'/exams',
'/certificates',
'/seaman-book',
'/endorsements',
'/documents',
// Vessel services — VESSEL_REGISTRATION is likewise mode-free.
'/vessel-registration',
// Waivers are filed by importers, who hold no logistics operating licence.
'/waiver',
];
/**
* Licence types seeded `requiresOperatorMode: false`.
*
* The wizard lives at one shared route (`/licensing/:typeCode/apply`), so the
* gate has to know which types behind it are mode-free — otherwise a seafarer
* or an importer reaches their service page and is bounced the moment they
* start the application. Mirrors the seeds; the server is still the authority,
* refusing a create for a mode the profile does not hold.
*/
const MODE_FREE_TYPE_KEYS = [
'SEAFARER_REGISTRATION',
'VESSEL_REGISTRATION',
'VESSEL_OWNERSHIP_TRANSFER',
'CERTIFICATE_OF_COMPETENCY',
'CERTIFICATE_OF_PROFICIENCY',
'ENDORSEMENT_COC',
'ENDORSEMENT_GOC',
'PRE_WAIVER',
'POST_WAIVER',
];
function isModeFreeLicensingRoute(pathname: string): boolean {
const match = pathname.match(/^\/licensing\/([^/]+)/);
return match ? MODE_FREE_TYPE_KEYS.includes(match[1]) : false;
}
export function RequireOperations({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { pathname } = useLocation();
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
if (
ALWAYS_ALLOWED.some((path) => pathname.startsWith(path)) ||
isModeFreeLicensingRoute(pathname)
) {
return <>{children}</>;
}
if (isLoading) {
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
}
// A failed lookup must not lock anyone out of the portal — the server still
// enforces the rule on create, so the worst case is a catalogue that offers
// more than it should for one session.
if (isError) return <>{children}</>;
if ((data?.items ?? []).length === 0) {
// An empty set while a request is in flight is not an answer. Saving the
// onboarding form invalidates this query and navigates to the dashboard in
// the same tick; deciding on the pre-save cache bounced the applicant
// straight back to the screen they had just completed.
if (isFetching) {
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
}
return <Navigate to="/onboarding/operations" replace />;
}
return <>{children}</>;
}