diff --git a/apps/portal/src/app/components/ProtectedRoute.tsx b/apps/portal/src/app/components/ProtectedRoute.tsx
new file mode 100644
index 000000000..1b5a4fd32
--- /dev/null
+++ b/apps/portal/src/app/components/ProtectedRoute.tsx
@@ -0,0 +1,13 @@
+import { Navigate, useLocation } from 'react-router-dom';
+import { useAppSelector } from '../store/hooks';
+
+export function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAppSelector((s) => s.auth.isAuthenticated);
+ const location = useLocation();
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/portal/src/app/features/dashboard/components/DashboardHero.tsx b/apps/portal/src/app/features/dashboard/components/DashboardHero.tsx
deleted file mode 100644
index 9c71d1b0b..000000000
--- a/apps/portal/src/app/features/dashboard/components/DashboardHero.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Box, Button, Group, Stack, Text, Title } from '@mantine/core';
-import { IconFilePlus, IconCategory, IconAnchor } from '@tabler/icons-react';
-import { useNavigate } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-
-export function DashboardHero({ name }: { name?: string }) {
- const { t } = useTranslation();
- const navigate = useNavigate();
-
- return (
-
- {/* decorative wash */}
-
-
-
-
-
-
- {t('dashboard.hero.greeting')}
- {name ? ',' : ''}
-
- {name && (
-
- {name}
-
- )}
-
- {t('dashboard.hero.subtitle')}
-
-
-
-
- }
- onClick={() => navigate('/apply')}
- >
- {t('dashboard.newApplication')}
-
- }
- onClick={() => navigate('/services')}
- styles={{
- root: {
- background: 'rgba(255,255,255,0.16)',
- color: 'white',
- border: '1px solid rgba(255,255,255,0.35)',
- backdropFilter: 'blur(4px)',
- },
- }}
- >
- {t('dashboard.hero.browse')}
-
-
-
-
- );
-}
diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts
index 38cf0c923..5a7c1c0e3 100644
--- a/apps/portal/src/app/i18n/locales/am.ts
+++ b/apps/portal/src/app/i18n/locales/am.ts
@@ -18,6 +18,8 @@ export const am: Translations = {
seafarerRegistry: 'የመርከበኞች ምዝገባ',
profile: 'መገለጫ',
support: 'እገዛና ድጋፍ',
+ collapseSidebar: 'ሰብስብ',
+ expandSidebar: 'ዘርጋ',
},
common: {
diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts
index cdc1589f5..7495259c1 100644
--- a/apps/portal/src/app/i18n/locales/en.ts
+++ b/apps/portal/src/app/i18n/locales/en.ts
@@ -16,6 +16,8 @@ export const en = {
seafarerRegistry: 'Seafarer Registry',
profile: 'Profile',
support: 'Help & Support',
+ collapseSidebar: 'Collapse',
+ expandSidebar: 'Expand sidebar',
},
common: {
diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx
index 14e792a4a..7592cf28e 100644
--- a/apps/portal/src/app/layouts/PortalLayout.tsx
+++ b/apps/portal/src/app/layouts/PortalLayout.tsx
@@ -27,7 +27,10 @@ import {
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { useDispatch } from 'react-redux';
import { notify } from '@ema-platform/ui';
+import { logout } from '@ema-platform/auth';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
import { BrandMark } from '@ema-platform/auth';
@@ -40,47 +43,44 @@ interface NavItem {
soon?: boolean;
}
-const NAV_ITEMS: NavItem[] = [
- { to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
- { to: '/seafarer-registry', label: 'Seafarer Registry', icon: IconList },
- { to: '/profile', label: 'Profile', icon: IconUser },
- { to: '/support', label: 'Support', icon: IconLifebuoy },
+const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
+ { to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconLayoutDashboard },
+ { to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
+ { to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUser },
+ { to: '/support', label: 'Support', i18nKey: 'nav.support', icon: IconLifebuoy },
];
-const PAGE_META: Record = {
+const PAGE_META: Record = {
'/dashboard': {
- title: 'Dashboard',
- subtitle: new Date().toLocaleDateString('en-GB', {
- weekday: 'long',
- day: '2-digit',
- month: 'long',
- year: 'numeric',
- }),
+ i18nKey: 'nav.dashboard',
+ subtitleKey: 'dashboard.title',
},
'/seafarer-registry': {
- title: 'Seafarer Registry',
- subtitle: 'Manage all registered seafarers',
+ i18nKey: 'nav.seafarerRegistry',
+ subtitleKey: '',
},
'/profile': {
- title: 'Profile',
- subtitle: 'Manage your account and preferences',
+ i18nKey: 'nav.profile',
+ subtitleKey: '',
},
};
export function PortalLayout() {
+ const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
+ const dispatch = useDispatch();
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
// Breadcrumb trail: always rooted at "Home", then any matched page segments.
const segments = location.pathname.split('/').filter(Boolean);
const crumbs = [
- { label: 'Home', path: '/dashboard' },
+ { label: t('nav.dashboard'), path: '/dashboard' },
...segments
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
.filter((path) => PAGE_META[path] && path !== '/dashboard')
- .map((path) => ({ label: PAGE_META[path].title, path })),
+ .map((path) => ({ label: t(PAGE_META[path].i18nKey), path })),
];
const go = (item: NavItem) => {
@@ -95,6 +95,7 @@ export function PortalLayout() {
};
const handleLogout = () => {
+ dispatch(logout());
navigate('/login');
};
@@ -381,7 +382,7 @@ export function PortalLayout() {
color: 'var(--mantine-color-primary-7)',
}}
>
- EMA Portal
+ {t('app.name')}
- Ethiopian Maritime Authority
+ {t('app.authority')}
)}
@@ -407,8 +408,9 @@ export function PortalLayout() {
const active = activeNavItem(item);
if (sidebarCollapsed) {
+ const label = t(item.i18nKey);
return (
-
+
go(item)}
style={{
@@ -428,11 +430,12 @@ export function PortalLayout() {
);
}
+ const label = t(item.i18nKey);
return (
}
onClick={() => go(item)}
variant="light"
@@ -452,7 +455,7 @@ export function PortalLayout() {
}}
>
- Collapse
+ {t('nav.collapseSidebar', 'Collapse')}
>
)}
diff --git a/apps/portal/src/app/router.tsx b/apps/portal/src/app/router.tsx
index 8b7638d9e..ffe76ae84 100644
--- a/apps/portal/src/app/router.tsx
+++ b/apps/portal/src/app/router.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { I18nextProvider } from 'react-i18next';
import { i18n } from './i18n/config';
import { PortalLayout } from './layouts/PortalLayout';
+import { ProtectedRoute } from './components/ProtectedRoute';
// Auth (standalone pages, no portal chrome)
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
@@ -28,19 +29,28 @@ function IsolatedIam({ children }: { children: ReactNode }) {
}
export const router = createBrowserRouter([
- // Standalone auth pages
+ // Public auth pages
{ path: '/login', element: },
{ path: '/signup', element: },
- { path: '/otp-verify', element: },
- { path: '/forgot-password', element: },
- // Portal — wrapped in the portal's own i18n instance so it is isolated from
- // the global i18next singleton that `@tria-plc/iamui-common` initializes.
+ // Protected auth pages
+ {
+ element: ,
+ path: '/otp-verify',
+ },
+ {
+ element: ,
+ path: '/forgot-password',
+ },
+
+ // Portal — protected
{
element: (
-
-
-
+
+
+
+
+
),
children: [
{ path: '/', element: },
@@ -53,12 +63,14 @@ export const router = createBrowserRouter([
],
},
- // IAM admin user management (isolated providers)
+ // IAM admin user management (isolated providers) — protected
{
element: (
-
-
-
+
+
+
+
+
),
children: [{ path: '/users', element: }],
},
diff --git a/libs/auth/src/lib/components/AuthShell.tsx b/libs/auth/src/lib/components/AuthShell.tsx
index 07b7c7204..f3e75ba54 100644
--- a/libs/auth/src/lib/components/AuthShell.tsx
+++ b/libs/auth/src/lib/components/AuthShell.tsx
@@ -7,11 +7,14 @@ import {
Stack,
Text,
Title,
+ UnstyledButton,
rem,
+ useComputedColorScheme,
+ useMantineColorScheme,
useMantineTheme,
type BoxProps,
} from '@mantine/core';
-import { IconCheck } from '@tabler/icons-react';
+import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react';
import { useAuthConfig } from '../AuthConfig';
export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) {
@@ -29,6 +32,34 @@ export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number
);
}
+function ThemeToggle() {
+ const { setColorScheme } = useMantineColorScheme();
+ const computed = useComputedColorScheme('light');
+ const isDark = computed === 'dark';
+
+ return (
+ setColorScheme(isDark ? 'light' : 'dark')}
+ aria-label="Toggle theme"
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: rem(36),
+ height: rem(36),
+ borderRadius: rem(10),
+ border: '1px solid var(--mantine-color-default-border)',
+ background: 'var(--mantine-color-body)',
+ color: 'var(--mantine-color-dimmed)',
+ cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ >
+ {isDark ? : }
+
+ );
+}
+
const FEATURES = [
'Submit applications online, 24/7',
'Real-time status tracking & alerts',
@@ -49,114 +80,137 @@ export function AuthShell({
const theme = useMantineTheme();
const { logoUrl } = useAuthConfig();
const heroGradient = theme.other.heroGradient as string;
+ const computed = useComputedColorScheme('light');
+ const isDark = computed === 'dark';
return (
-
+ {/* Theme toggle — top-right corner */}
-
-
- {children}
-
+
+
-
+
+
+
-
-
-
-
-
-
+ {children}
+
-
-
- {brandTitle}
-
-
- {brandSubtitle}
+
+
+
+
+
+
+
+
+
+
+
+ {brandTitle}
+
+
+ {brandSubtitle}
+
+
+
+
+ {FEATURES.map((feature) => (
+
+
+
+
+
+ {feature}
+
+
+ ))}
+
+
+
+ © 2026 Ethiopian Maritime Authority
-
-
- {FEATURES.map((feature) => (
-
-
-
-
-
- {feature}
-
-
- ))}
-
-
-
- © 2026 Ethiopian Maritime Authority
-
-
-
-
-
-
+
+
+
+
+
);
}