fix: make vessel registeration in its own routes

This commit is contained in:
mengstabketemaw
2026-07-08 16:46:27 +03:00
parent de37c6940e
commit 1471c797d9
9 changed files with 80 additions and 358 deletions

View File

@@ -1,17 +1,53 @@
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Button, Center, Stack, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
import { authStorage } from '@ema-platform/auth';
const ROUTE_ACCESS: Record<string, string[]> = {
SEAFARER: ['/dashboard', '/seafarer-', '/seaman-book', '/documents', '/certificates', '/endorsements', '/notifications', '/basic-safety-training'],
VESSEL_OWNER: ['/vessel-owner', '/vessel-registration'],
};
const SHARED = ['/profile', '/support'];
function ForbiddenPage({ path, type }: { path: string; type: string }) {
const navigate = useNavigate();
const dashboard = type === 'VESSEL_OWNER' ? '/vessel-owner/dashboard' : '/dashboard';
return (
<Center mih="100vh">
<Stack align="center" gap="md">
<Title order={1} c="red">403</Title>
<Title order={3}>Access Denied</Title>
<Text ta="center" maw={480}>
You do not have permission to access <Text span fw={700} component="code">{path}</Text>.
This section is not available for your account type.
</Text>
<Button onClick={() => navigate(dashboard)}>
Go to Dashboard
</Button>
</Stack>
</Center>
);
}
interface ProfileGuardProps {
children?: ReactNode;
}
export function ProfileGuard({ children }: ProfileGuardProps) {
const profileId = authStorage.getProfileId();
const profile = authStorage.getProfile<{ id: string; type: string }>();
const location = useLocation();
if (!profileId) {
if (!profile) {
return <Navigate to="/profile-setup" replace />;
}
const allowed = [...(ROUTE_ACCESS[profile.type] ?? []), ...SHARED];
const isAllowed = allowed.some((p) => location.pathname === p || location.pathname.startsWith(p));
if (!isAllowed) {
return <ForbiddenPage path={location.pathname} type={profile.type} />;
}
return <>{children}</>;
}

View File

@@ -24,7 +24,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth';
import { authStorage, setUser, setCurrentProfile, logout, type AuthUser, type CurrentProfile } from '@ema-platform/auth';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
ProfileFormContent,
@@ -260,11 +260,9 @@ export function ProfileSetupPage() {
},
}).unwrap();
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
notify.success('Profile setup complete!');
navigate('/dashboard');
//TODO: update the comparision
navigate('SEAFARER' === 'VESSEL_OWNER' ? '/vessel-owner/dashboard' : '/dashboard');
} catch {
notify.error('Failed to save profile. Please try again.');
} finally {

View File

@@ -1,128 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Center,
Divider,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconLock,
IconMail,
IconShip,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
export function VesselOwnerLoginPage() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [loginTrigger] = useApiMutation<{ token: string; user: { id: string; name: string } }>();
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
setError('Please enter your email and password.');
return;
}
setError('');
setLoading(true);
try {
await loginTrigger({
url: '/auth/vessel-owner/login',
method: 'POST',
body: { email, password },
}).unwrap();
notify.success('Login successful. Welcome!');
navigate('/vessel-owner/dashboard');
} catch {
setError('Invalid email or password. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={440} px="md">
{/* Brand */}
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Vessel Owner Portal</Title>
<Text fz="sm" c="dimmed" ta="center">
Ethiopian Maritime Affairs Authority
</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Sign In</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{error}
</Alert>
)}
<Stack gap="md">
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
leftSection={<IconLock size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
<Anchor fz="sm" ta="right" onClick={() => navigate('/vessel-owner/forgot-password')}>
Forgot password?
</Anchor>
<Button fullWidth size="md" loading={loading} onClick={handleLogin} leftSection={<IconAnchor size={16} />}>
Sign In
</Button>
</Stack>
<Divider my="md" label="Don't have an account?" labelPosition="center" />
<Button
fullWidth
variant="light"
onClick={() => navigate('/vessel-owner/register')}
>
Create Vessel Owner Account
</Button>
</Paper>
<Text fz="xs" c="dimmed" ta="center">
This portal is exclusively for vessel owners. For seafarer services,{' '}
<Anchor fz="xs" onClick={() => navigate('/login')}>sign in here</Anchor>.
</Text>
</Stack>
</Box>
);
}

View File

@@ -1,199 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Box,
Button,
Divider,
Group,
Paper,
PasswordInput,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconAlertCircle,
IconCheck,
IconLock,
IconMail,
IconPhone,
IconShip,
IconUser,
} from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const OWNER_TYPES = [
'Individual (Private Owner)',
'Private Company / PLC',
'State Enterprise',
'NGO / Non-Profit',
'Government Agency',
];
export function VesselOwnerRegisterPage() {
const navigate = useNavigate();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [ownerType, setOwnerType] = useState<string | null>(null);
const [nationalIdOrTin, setNationalIdOrTin] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [registerTrigger] = useApiMutation<{ id: string }>();
const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword;
const handleRegister = async () => {
if (!canSubmit) {
setError('Please fill in all required fields. Passwords must match.');
return;
}
setError('');
setLoading(true);
try {
await registerTrigger({
url: '/auth/vessel-owner/register',
method: 'POST',
body: { fullName, email, phone, ownerType, nationalIdOrTin, password },
}).unwrap();
setSuccess(true);
notify.success('Account created! You can now sign in.');
} catch {
setError('Registration failed. This email may already be registered.');
} finally {
setLoading(false);
}
};
if (success) {
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Paper withBorder radius="lg" p="xl" w="100%" maw={440} shadow="sm" mx="md">
<Stack align="center" gap="md">
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
<IconCheck size={30} />
</ThemeIcon>
<Title order={3} ta="center">Account Created!</Title>
<Text fz="sm" c="dimmed" ta="center">
Your vessel owner account has been created. You can now sign in and submit vessel registration applications.
</Text>
<Button fullWidth onClick={() => navigate('/vessel-owner/login')}>
Sign In Now
</Button>
</Stack>
</Paper>
</Box>
);
}
return (
<Box style={{ minHeight: '100vh', background: 'var(--mantine-color-gray-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="xl" w="100%" maw={540} px="md">
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" color="blue" variant="light">
<IconShip size={36} />
</ThemeIcon>
<Title order={2} ta="center">Create Vessel Owner Account</Title>
<Text fz="sm" c="dimmed" ta="center">Ethiopian Maritime Affairs Authority</Text>
</Stack>
<Paper withBorder radius="lg" p="xl" w="100%" shadow="sm">
<Group gap="xs" mb="lg">
<IconAnchor size={20} color="var(--mantine-color-blue-6)" />
<Text fw={700} fz="lg">Owner Registration</Text>
</Group>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">{error}</Alert>
)}
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput
label="Full Name / Company Name"
placeholder="e.g. Abebe Girma"
leftSection={<IconUser size={16} />}
required
value={fullName}
onChange={(e) => setFullName(e.currentTarget.value)}
/>
<Select
label="Owner Type"
placeholder="Select type"
required
data={OWNER_TYPES}
value={ownerType}
onChange={setOwnerType}
/>
<TextInput
label="Email Address"
placeholder="owner@example.com"
leftSection={<IconMail size={16} />}
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<TextInput
label="Phone Number"
placeholder="+251 9XX XXX XXX"
leftSection={<IconPhone size={16} />}
required
value={phone}
onChange={(e) => setPhone(e.currentTarget.value)}
/>
<TextInput
label="National ID / TIN"
placeholder="ET-0000000 or TIN"
required
value={nationalIdOrTin}
onChange={(e) => setNationalIdOrTin(e.currentTarget.value)}
/>
</SimpleGrid>
<Divider label="Set Password" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="Min. 8 characters"
leftSection={<IconLock size={16} />}
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<PasswordInput
label="Confirm Password"
placeholder="Repeat password"
leftSection={<IconLock size={16} />}
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
error={confirmPassword && password !== confirmPassword ? 'Passwords do not match' : undefined}
/>
</SimpleGrid>
<Button fullWidth size="md" loading={loading} disabled={!canSubmit} onClick={handleRegister}>
Create Account
</Button>
</Stack>
<Divider my="md" label="Already have an account?" labelPosition="center" />
<Button fullWidth variant="light" onClick={() => navigate('/vessel-owner/login')}>
Sign In
</Button>
</Paper>
</Stack>
</Box>
);
}

View File

@@ -25,6 +25,10 @@ export const am: Translations = {
support: 'እገዛና ድጋፍ',
collapseSidebar: 'ሰብስብ',
expandSidebar: 'ዘርጋ',
myVessels: 'መርከቦቼ',
vesselRegistration: 'የመርከብ ምዝገባ',
ownershipTransfer: 'የባለቤትነት ዝውውር',
basicSafetyTraining: 'መሰረታዊ የደህንነት ስልጠና',
},
common: {

View File

@@ -23,6 +23,10 @@ export const en = {
support: 'Help & Support',
collapseSidebar: 'Collapse',
expandSidebar: 'Expand sidebar',
myVessels: 'My Vessels',
vesselRegistration: 'Vessel Registration',
ownershipTransfer: 'Ownership Transfer',
basicSafetyTraining: 'Basic Safety Training',
},
common: {

View File

@@ -1,6 +1,7 @@
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconAnchor,
IconBell,
IconFolderOpen,
IconHeadset,
@@ -9,6 +10,7 @@ import {
IconRubberStamp,
IconSend,
IconShieldCheck,
IconShip,
IconUserCircle,
} from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
@@ -20,16 +22,20 @@ import { logout } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppSelector } from '../store/hooks';
const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconHome2 },
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
{ to: '/notifications',label: 'Notifications', i18nKey: 'nav.notifications', icon: IconBell },
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUserCircle },
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconHeadset },
const NAV_ITEMS: (NavItem & { i18nKey: string; allowedTypes: string[] })[] = [
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconHome2, allowedTypes: ['SEAFARER'] },
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList, allowedTypes: ['SEAFARER'] },
{ to: '/seaman-book', label: 'My Application', i18nKey: 'nav.myApplication', icon: IconSend, allowedTypes: ['SEAFARER'] },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, allowedTypes: ['SEAFARER'] },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, allowedTypes: ['SEAFARER'] },
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen, allowedTypes: ['SEAFARER'] },
{ to: '/notifications', label: 'Notifications', i18nKey: 'nav.notifications', icon: IconBell, allowedTypes: ['SEAFARER'] },
{ to: '/basic-safety-training', label: 'Basic Safety Training', i18nKey: 'nav.basicSafetyTraining', icon: IconShieldCheck, allowedTypes: ['SEAFARER'] },
{ to: '/vessel-owner/dashboard', label: 'My Vessels', i18nKey: 'nav.myVessels', icon: IconShip, allowedTypes: ['VESSEL_OWNER'] },
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconAnchor, allowedTypes: ['VESSEL_OWNER'] },
{ to: '/vessel-registration/transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconAnchor, allowedTypes: ['VESSEL_OWNER'] },
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUserCircle, allowedTypes: ['SEAFARER', 'VESSEL_OWNER'] },
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconHeadset, allowedTypes: ['SEAFARER', 'VESSEL_OWNER'] },
];
const PAGE_META: Record<string, { i18nKey: string }> = {
@@ -79,6 +85,9 @@ export function PortalLayout() {
navigate('/login');
};
const profile = useAppSelector((s) => s.auth.currentProfile);
const filteredNavItems = NAV_ITEMS.filter((item) => profile && item.allowedTypes.includes(profile.type));
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
@@ -123,7 +132,7 @@ export function PortalLayout() {
}}
>
<AppSidebar
navItems={NAV_ITEMS.map(({ i18nKey, ...rest }) => ({ ...rest, label: t(i18nKey) }))}
navItems={filteredNavItems.map(({ i18nKey, allowedTypes: _, ...rest }) => ({ ...rest, label: t(i18nKey) }))}
collapsed={sidebarCollapsed}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}

View File

@@ -39,10 +39,7 @@ import { VesselRegistrationPage } from './features/vessel-registration/pages/Ves
import { VesselRegistrationApplicationPage } from './features/vessel-registration/pages/VesselRegistrationApplicationPage';
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
// Vessel Owner Portal (restricted)
import { VesselOwnerLayout } from './layouts/VesselOwnerLayout';
import { VesselOwnerLoginPage } from './features/vessel-owner/pages/VesselOwnerLoginPage';
import { VesselOwnerRegisterPage } from './features/vessel-owner/pages/VesselOwnerRegisterPage';
// Vessel Owner
import { VesselOwnerDashboardPage } from './features/vessel-owner/pages/VesselOwnerDashboardPage';
@@ -51,15 +48,14 @@ export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> },
// Public auth pages
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
// Protected auth pages
{
element: <ProtectedRoute><OTPVerificationPage /></ProtectedRoute>,
path: '/otp-verify',
},
{
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
path: '/forgot-password',
},
{
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
path: '/profile-setup',
@@ -106,9 +102,9 @@ export const router = createBrowserRouter([
{ path: '/vessel-registration/apply', element: <VesselRegistrationApplicationPage /> },
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
// Vessel Owner Portal — public login/register
{ path: '/vessel-owner/login', element: <VesselOwnerLoginPage /> },
{ path: '/vessel-owner/register', element: <VesselOwnerRegisterPage /> },
// Vessel Owner
{ path: '/vessel-owner/dashboard', element: <VesselOwnerDashboardPage /> },
{ path: '/vessel-owner', element: <Navigate to="/vessel-owner/dashboard" replace /> },
// General
{ path: '/profile', element: <ProfilePage /> },
{ path: '/support', element: <SupportPage /> },

View File

@@ -75,6 +75,7 @@ export function LoginPage() {
dispatch(setUser(me));
let hasProfile = false;
let profileType: string | undefined;
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({
@@ -83,6 +84,7 @@ export function LoginPage() {
}).unwrap();
if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0];
profileType = 'VESSEL_OWNER';
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
hasProfile = true;
@@ -107,7 +109,7 @@ export function LoginPage() {
return;
}
navigate(loginRedirectPath);
navigate(profileType === 'VESSEL_OWNER' ? '/vessel-owner/dashboard' : loginRedirectPath);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??