feat: ( permission ) add permission guard

This commit is contained in:
Abubeker Yasin
2026-06-26 15:49:14 +03:00
parent e2dbd27222
commit 8678258157
10 changed files with 249 additions and 38 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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}</>;
}

View File

@@ -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>

View File

@@ -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);
},
}));

View 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;

View 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));
}

View 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).*)'],
};

View File

@@ -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