mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( permission ) add permission guard
This commit is contained in:
@@ -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 }) => (
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function BookingsPage() {
|
||||
function BookingsPageContent() {
|
||||
const canManage = usePermission(PERMS.bookings.manage);
|
||||
const canCancel = usePermission(PERMS.bookings.cancel);
|
||||
const [filters, setFilters] = useState<BookingFilters>({ 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() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingsPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.bookings.view}>
|
||||
<BookingsPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<PermissionGuard permission={PERMS.dashboard}>
|
||||
<DashboardPageContent />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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 */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
||||
{navigationSections.map((section) => (
|
||||
{navigationSections.map((section) => {
|
||||
const visibleItems = section.items.filter(
|
||||
(item) => !item.permission || hasPermission(item.permission)
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<div key={section.title}>
|
||||
{!isCollapsed && (
|
||||
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-white/60 dark:text-slate-400">
|
||||
@@ -162,7 +183,7 @@ export default function Sidebar() {
|
||||
</h3>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
{visibleItems.map((item) => {
|
||||
// Special handling for Settings to avoid conflict with User Management
|
||||
let isActive;
|
||||
if (item.href === '/settings') {
|
||||
@@ -194,7 +215,8 @@ export default function Sidebar() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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<AuthState>((set) => ({
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
@@ -34,7 +37,11 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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<AuthState>((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);
|
||||
},
|
||||
}));
|
||||
|
||||
42
apps/edr-passenger-web/backoffice/src/lib/permissions.ts
Normal file
42
apps/edr-passenger-web/backoffice/src/lib/permissions.ts
Normal file
@@ -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;
|
||||
15
apps/edr-passenger-web/backoffice/src/lib/use-permission.ts
Normal file
15
apps/edr-passenger-web/backoffice/src/lib/use-permission.ts
Normal file
@@ -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 && <button>Cancel Booking</button>}
|
||||
*/
|
||||
export function usePermission(key: string): boolean {
|
||||
return useAuthStore((s) => s.hasPermission(key));
|
||||
}
|
||||
24
apps/edr-passenger-web/backoffice/src/middleware.ts
Normal file
24
apps/edr-passenger-web/backoffice/src/middleware.ts
Normal file
@@ -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).*)'],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user