From 6fce0b4fa7b09b1f7ae082c71c61299ea179d922 Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 1 Aug 2026 08:23:37 +0000 Subject: [PATCH] feat: add profile check mutation and enhance navigation access control --- .../profile-setup/pages/ProfileSetupPage.tsx | 21 ++++++++++++++ apps/portal/src/app/layouts/PortalLayout.tsx | 29 +++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx index ab6c32fde..5360eb39e 100644 --- a/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx +++ b/apps/portal/src/app/features/profile-setup/pages/ProfileSetupPage.tsx @@ -27,8 +27,10 @@ import { notify, useErrorHandler } from "@ema-platform/ui"; import { authStorage, setUser, + setCurrentProfile, logout, type AuthUser, + type CurrentProfile, } from "@ema-platform/auth"; import { useAppDispatch, useAppSelector } from "../../../store/hooks"; import { @@ -144,6 +146,10 @@ export function ProfileSetupPage() { const [profileTrigger] = useApiMutation<{ id: string }>(); const [addressTrigger] = useApiMutation<{ id: string }>(); const [meTrigger] = useApiMutation(); + const [profileCheckTrigger] = useApiMutation<{ + total: number; + items: CurrentProfile[]; + }>(); const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }>; @@ -300,6 +306,21 @@ export function ProfileSetupPage() { const me = await meTrigger({ url: "/auth/me", method: "GET" }).unwrap(); dispatch(setUser(me)); + // POST /profiles doesn't return the profession relation the portal's nav/route + // guard reads (PortalLayout.tsx) — re-fetch with it, same as LoginPage does on login. + try { + const q = `w=user_id:=:${me.id}&i=user,address,profession`; + const profileCheck = await profileCheckTrigger({ + url: `/profiles?q=${encodeURIComponent(q)}`, + method: "GET", + }).unwrap(); + if (profileCheck.total > 0 && profileCheck.items.length > 0) { + dispatch(setCurrentProfile(profileCheck.items[0])); + } + } catch { + // non-fatal — sidebar/route guard falls back to FALLBACK_ACCESS + } + notify.success("Profile setup complete!"); navigate("/dashboard"); } catch (e) { diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index 80d821257..ec81d0332 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -19,7 +19,7 @@ import { IconUsers, IconAnchor, } from "@tabler/icons-react"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useDispatch } from "react-redux"; import { notify, AppHeader, AppSidebar } from "@ema-platform/ui"; @@ -27,6 +27,7 @@ import type { NavItem } from "@ema-platform/ui"; import { logout } from "@ema-platform/auth"; import { SUPPORTED_LANGUAGES } from "../i18n/config"; import { useAppSelector } from "../store/hooks"; +import { PAYMENTS_ROUTE } from "../features/payment/constants"; type SectionId = "seafarer" | "vessel" | "logistics" | "common"; @@ -180,11 +181,16 @@ const NAV_ITEMS: (NavItem & { i18nKey: string; section?: SectionId })[] = [ const ALWAYS_VISIBLE = ["/notifications", "/profile", "/support", "/documents"]; +// Routes reachable by URL but absent from NAV_ITEMS — listing them here has no sidebar +// effect, it only keeps the route guard below from blocking them for everyone. +const ALWAYS_ALLOWED = [PAYMENTS_ROUTE]; + const NAV_ACCESS: Record = { null: "*", // cadet: "*", cadet: [ "/dashboard", + "/seafarer-registration", "/seafarer-registry", "/seaman-book", "/certificates", @@ -195,11 +201,13 @@ const NAV_ACCESS: Record = { "/vessel-registration-dashboard", "/vessel-registration", "/vessel-registration/transfer", + "/vessel-registrations", ], transfer: [ "/vessel-registration-dashboard", "/vessel-registration", "/vessel-registration/transfer", + "/vessel-registrations", ], logistics: [ "/logistics-dashboard", @@ -213,7 +221,13 @@ const NAV_ACCESS: Record = { }; // Used when profession is missing or matches no key above. Set to '*' to show everything. -const FALLBACK_ACCESS: string[] | "*" = ["/dashboard"]; +const FALLBACK_ACCESS: string[] | "*" = ["/dashboard", "/seafarer-registration"]; + +// Matches `pathname` against `base` itself or any of its sub-paths, so child routes +// (e.g. "/seaman-book/apply") inherit their parent's grant without loose prefix +// matches (e.g. "/vessel-registration" must not match "/vessel-registration-dashboard"). +const matchesPath = (pathname: string, base: string) => + pathname === base || pathname.startsWith(base + "/"); const PAGE_META: Record = { "/dashboard": { i18nKey: "nav.dashboard" }, @@ -254,6 +268,12 @@ export function PortalLayout() { ); const allowed = (matchedKey ? NAV_ACCESS[matchedKey] : undefined) ?? FALLBACK_ACCESS; + const home = allowed === "*" ? "/dashboard" : (allowed[0] ?? "/dashboard"); + const isAllowed = + allowed === "*" || + [...ALWAYS_VISIBLE, ...ALWAYS_ALLOWED, ...allowed].some((p) => + matchesPath(location.pathname, p), + ); const visibleNavItems = allowed === "*" ? NAV_ITEMS @@ -265,6 +285,11 @@ export function PortalLayout() { const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure(); const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false); + if (!isAllowed) { + notify.error(t("msg.permissionError")); + return ; + } + // Breadcrumb trail const segments = location.pathname.split("/").filter(Boolean); const crumbs = [