From 8678258157ed9b42a69f2f2b799119a78698419b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 26 Jun 2026 15:49:14 +0300 Subject: [PATCH] feat: ( permission ) add permission guard --- .../backoffice/src/app/bookings/page.tsx | 15 +++- .../backoffice/src/app/dashboard/page.tsx | 12 ++- .../backoffice/src/app/login/page.tsx | 7 +- .../src/components/layout/PermissionGuard.tsx | 36 ++++++++ .../src/components/layout/Sidebar.tsx | 86 ++++++++++++------- .../backoffice/src/lib/auth-store.ts | 47 +++++++++- .../backoffice/src/lib/permissions.ts | 42 +++++++++ .../backoffice/src/lib/use-permission.ts | 15 ++++ .../backoffice/src/middleware.ts | 24 ++++++ .../backoffice/src/types/index.ts | 3 + 10 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/lib/permissions.ts create mode 100644 apps/edr-passenger-web/backoffice/src/lib/use-permission.ts create mode 100644 apps/edr-passenger-web/backoffice/src/middleware.ts diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index d1b7cdf87..6caa5733b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -8,6 +8,9 @@ import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { usePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { bookingsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; @@ -26,7 +29,9 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function BookingsPage() { +function BookingsPageContent() { + const canManage = usePermission(PERMS.bookings.manage); + const canCancel = usePermission(PERMS.bookings.cancel); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -481,3 +486,11 @@ export default function BookingsPage() { ); } + +export default function BookingsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index d61acff41..66d5ebeb6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -1,6 +1,8 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; @@ -11,7 +13,7 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; -export default function DashboardPage() { +function DashboardPageContent() { const { data: stats, isLoading: statsLoading } = useQuery({ queryKey: ['dashboard-stats'], queryFn: dashboardApi.getStats, @@ -237,3 +239,11 @@ export default function DashboardPage() { ); } + +export default function DashboardPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index 0e821873e..d9c9b6220 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -42,7 +42,12 @@ export default function LoginPage() { await login(email, password); router.push('/dashboard'); } catch (err: any) { - setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.'); + const msg = err.message || err.response?.data?.message || ''; + if (msg === 'ACCESS_DENIED') { + setError('This account does not have back-office access. Contact your administrator.'); + } else { + setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.'); + } } finally { setLoading(false); } diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx new file mode 100644 index 000000000..2da55e37f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/lib/auth-store'; + +interface Props { + permission?: string; + children: React.ReactNode; +} + +/** + * Wraps a page to enforce auth + optional permission check. + * - Not logged in → redirect to /login + * - Missing permission → redirect to /dashboard + */ +export function PermissionGuard({ permission, children }: Props) { + const router = useRouter(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const hasPermission = useAuthStore((s) => s.hasPermission); + + useEffect(() => { + if (!isAuthenticated) { + router.replace('/login'); + return; + } + if (permission && !hasPermission(permission)) { + router.replace('/dashboard'); + } + }, [isAuthenticated, permission, hasPermission, router]); + + if (!isAuthenticated) return null; + if (permission && !hasPermission(permission)) return null; + + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 96798936b..a2a9f68b0 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -39,49 +39,65 @@ import { import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; import { useTheme } from '@/lib/theme-store'; +import { PERMS } from '@/lib/permissions'; -const navigationSections = [ +interface NavItem { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + permission?: string; +} + +const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Overview', items: [ - { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, permission: PERMS.dashboard }, ] }, { title: 'Operations', items: [ - { name: 'Bookings', href: '/bookings', icon: Ticket }, - { name: 'Passengers', href: '/passengers', icon: Users }, - { name: 'Tickets', href: '/tickets', icon: FileText }, - { name: 'Lugagges', href: '/excess-baggage', icon: Banknote }, + { name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view }, + { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, + { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, ] }, { title: 'Tourism', items: [ - { name: 'Packages', href: '/packages', icon: Package }, - { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare }, + { name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin }, + { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin }, ] }, { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin }, - { name: 'Trains', href: '/trains', icon: Train }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, - { name: 'Seats', href: '/seats', icon: Armchair }, - { name: 'Classes', href: '/classes', icon: Settings }, - { name: 'Routes', href: '/routes', icon: Route }, - { name: 'Schedules', href: '/schedules', icon: Calendar }, + { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin }, + { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin }, + { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin }, + { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin }, + { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin }, + { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin }, ] }, { title: 'Financial', items: [ - { name: 'Fares', href: '/pricing', icon: DollarSign }, - { name: 'Currencies', href: '/currencies', icon: Banknote }, - { name: 'Payments', href: '/payments', icon: CreditCard }, - { name: 'Promos', href: '/promos', icon: Gift }, + { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, + { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage }, + { name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view }, + { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, + ] + }, + { + title: 'Customer Services', + items: [ + { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, + { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, + { name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send }, ] }, // { @@ -95,32 +111,32 @@ const navigationSections = [ { title: 'Security & Compliance', items: [ - { name: 'Logs', href: '/audit', icon: AlertTriangle }, - { name: 'Fraud', href: '/fraud', icon: Shield }, - { name: 'Verifayda', href: '/verifayda', icon: UserCheck }, + { name: 'Audit Logs', href: '/audit', icon: AlertTriangle, permission: PERMS.audit.view }, + { name: 'Fraud Detection', href: '/fraud', icon: Shield, permission: PERMS.fraud.view }, + { name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck, permission: PERMS.admin }, ] }, { title: 'Analytics & Reports', items: [ - { name: 'Reports', href: '/reports', icon: BarChart3 }, - { name: 'Operational', href: '/operational-reports', icon: FileText }, + { name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] }, { title: 'System', items: [ - { name: 'Agents', href: '/agents', icon: Briefcase }, - { name: 'Users', href: '/settings/users', icon: Users }, - { name: 'Settings', href: '/settings', icon: Settings }, - { name: 'Health', href: '/health', icon: Activity }, + { name: 'Agent Operations', href: '/agents', icon: Briefcase, permission: PERMS.agents.view }, + { name: 'User Management', href: '/settings/users', icon: Users, permission: PERMS.admin }, + { name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin }, + { name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin }, ] } ]; export default function Sidebar() { const pathname = usePathname(); - const { user, logout } = useAuthStore(); + const { user, logout, hasPermission } = useAuthStore(); const { isDark, toggleTheme } = useTheme(); const [isCollapsed, setIsCollapsed] = useState(false); @@ -154,7 +170,12 @@ export default function Sidebar() { {/* Navigation */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts index fcba670b6..4048e6d0e 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts @@ -1,3 +1,5 @@ +'use client'; + import { create } from 'zustand'; import { AdminUser } from '@/types'; import axios from 'axios'; @@ -20,9 +22,10 @@ interface AuthState { logout: () => void; setUser: (user: AdminUser, token: string) => void; initialize: () => void; + hasPermission: (key: string) => boolean; } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create((set, get) => ({ user: null, token: null, refreshToken: null, @@ -34,7 +37,11 @@ export const useAuthStore = create((set) => ({ const userStr = localStorage.getItem('auth_user'); if (token && userStr) { try { - const user = JSON.parse(userStr); + const user = JSON.parse(userStr) as AdminUser; + // backfill for sessions stored before permissions were added + if (!user.permissions) user.permissions = []; + if (user.isSuperAdmin === undefined) user.isSuperAdmin = false; + if (user.isOrgAdmin === undefined) user.isOrgAdmin = false; set({ user, token, isAuthenticated: true }); } catch { localStorage.removeItem('auth_token'); @@ -51,23 +58,49 @@ export const useAuthStore = create((set) => ({ const { token, refreshToken } = loginData; if (!token) throw new Error('No token received from server'); - // Step 2: fetch full user info with the token + // Step 2: fetch full user from IAM /v1/auth/me — returns session.userInfo + // employee is an array here (unlike /auth/me which transforms it to a single object via parseToken) const meRes = await axios.get(`${API_URL}/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` }, }); const iamUser = meRes.data?.data ?? meRes.data; + // Role permissions — flat array in data.permissions + const rolePerms = (iamUser.permissions ?? []).map((p: any) => String(p.key)); + // Position permissions — employee[] is an array here; positions[].permissions[] merged by IAM + const employeeArr: any[] = Array.isArray(iamUser.employee) ? iamUser.employee : []; + const positionPerms = employeeArr.flatMap((emp: any) => + (emp.positions ?? []).flatMap((pos: any) => + (pos.permissions ?? []).map((p: any) => String(p.key)) + ) + ); + const permissions = Array.from(new Set([...rolePerms, ...positionPerms])); + + const isSuperAdmin = iamUser.isSuperAdmin ?? false; + const isOrgAdmin = iamUser.isOrganizationAdmin ?? false; + + // Block individual (passenger) accounts — backoffice requires at least one of: + // super admin, org admin, an employee position, or an explicit permission. + if (!isSuperAdmin && !isOrgAdmin && employeeArr.length === 0 && permissions.length === 0) { + throw new Error('ACCESS_DENIED'); + } + const user: AdminUser = { id: iamUser.id, email: iamUser.email, fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email, role: mapIamRole(iamUser.roles ?? []), active: true, + permissions, + isSuperAdmin, + isOrgAdmin, }; localStorage.setItem('auth_token', token); localStorage.setItem('auth_user', JSON.stringify(user)); if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken); + // cookie lets middleware detect auth without reading localStorage + document.cookie = `auth_token=${token}; path=/; SameSite=Lax`; set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true }); }, @@ -76,10 +109,18 @@ export const useAuthStore = create((set) => ({ localStorage.removeItem('auth_token'); localStorage.removeItem('auth_refresh_token'); localStorage.removeItem('auth_user'); + document.cookie = 'auth_token=; path=/; max-age=0'; set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); }, setUser: (user: AdminUser, token: string) => { set({ user, token, isAuthenticated: true }); }, + + hasPermission: (key: string) => { + const { user } = get(); + if (!user) return false; + if (user.isSuperAdmin || user.isOrgAdmin) return true; + return user.permissions.includes(key); + }, })); diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts new file mode 100644 index 000000000..7731d784e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts @@ -0,0 +1,42 @@ +export const PERMS = { + dashboard: 'edr_passenger_app:dashboard:view', + bookings: { + view: 'edr_passenger_app:bookings:view', + manage: 'edr_passenger_app:bookings:manage', + cancel: 'edr_passenger_app:bookings:cancel', + }, + passengers: { + view: 'edr_passenger_app:passengers:view', + manage: 'edr_passenger_app:passengers:manage', + }, + tickets: { + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + }, + payments: { + view: 'edr_passenger_app:payments:view_all', + refund: 'edr_passenger_app:payments:refund', + manage: 'edr_passenger_app:payments:manage_methods', + }, + reports: { + view: 'edr_passenger_app:reports:view', + }, + fraud: { + view: 'edr_passenger_app:fraud:view', + manage: 'edr_passenger_app:fraud:manage', + }, + audit: { + view: 'edr_passenger_app:audit:view', + }, + agents: { + view: 'edr_passenger_app:agents:view', + manage: 'edr_passenger_app:agents:manage', + }, + currencies: { + manage: 'edr_passenger_app:currencies:manage', + }, + notifications: { + send: 'edr_passenger_app:notifications:send', + }, + admin: 'edr_passenger_app:admin', +} as const; diff --git a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts new file mode 100644 index 000000000..0991a360e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useAuthStore } from './auth-store'; + +/** + * Returns whether the current user has a given permission key. + * Super admins and org admins always return true. + * + * Usage: + * const canCancel = usePermission(PERMS.bookings.cancel); + * {canCancel && } + */ +export function usePermission(key: string): boolean { + return useAuthStore((s) => s.hasPermission(key)); +} diff --git a/apps/edr-passenger-web/backoffice/src/middleware.ts b/apps/edr-passenger-web/backoffice/src/middleware.ts new file mode 100644 index 000000000..0b437d112 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/middleware.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const PUBLIC_PATHS = ['/login']; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { + return NextResponse.next(); + } + + // Token is stored in localStorage (client-side only), so middleware can't + // read it directly. We use a cookie set on login as the server-side signal. + const token = request.cookies.get('auth_token')?.value; + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'], +}; diff --git a/apps/edr-passenger-web/backoffice/src/types/index.ts b/apps/edr-passenger-web/backoffice/src/types/index.ts index 5ec6f4e1d..f853ec95e 100644 --- a/apps/edr-passenger-web/backoffice/src/types/index.ts +++ b/apps/edr-passenger-web/backoffice/src/types/index.ts @@ -96,6 +96,9 @@ export interface AdminUser { fullName: string; role: 'ADMIN' | 'AGENT' | 'SUPERVISOR'; active: boolean; + permissions: string[]; + isSuperAdmin: boolean; + isOrgAdmin: boolean; } // Re-export EDR types