diff --git a/src/App.tsx b/src/App.tsx index 05715ff..804d6d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,15 @@ -import { BrowserRouter } from 'react-router-dom' -import AppRoutes from './AppRoutes' +import { BrowserRouter } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; +import AppRoutes from './AppRoutes'; function App() { return ( - + + + - ) + ); } -export default App +export default App; diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 32da98b..66e08a0 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -1,40 +1,157 @@ import { lazy, Suspense } from "react"; import { Route, Routes, Navigate } from "react-router-dom"; import Layout from "./layout/AppLayout"; +import ProtectedRoute from "./components/common/ProtectedRoute"; - -const HomePage = lazy(() => import('./app/dashboard')) -const CohortManage = lazy(() => import('./app/cohartManage')) -const PolicyEngineList = lazy(() => import('./app/policyEngine/components/PolicyEngineList')) -const AddPolicyEngine = lazy(() => import('./app/policyEngine/components/AddPolicyEngine')) -const RecoveryIncidentsList = lazy(() => import('./app/recoveryIncidents/components/RecoveryIncidentsList')) -const RecoveryIncidentTabs = lazy(() => import('./app/recoveryIncidents/tabs/index')) -const AuditLogsList = lazy(() => import('./app/auditLogs/components/AuditLogsList')) -const ConfigurationPage = lazy(() => import('./app/configuration')) -const SimulationPage = lazy(() => import('./app/simulation')) +const LoginPage = lazy(() => import("./app/authentication")); +const HomePage = lazy(() => import("./app/dashboard")); +const SimulationPage = lazy(() => import("./app/simulation")); +const CohortManage = lazy(() => import("./app/cohartManage")); +const PolicyEngineList = lazy(() => import("./app/policyEngine/components/PolicyEngineList")); +const AddPolicyEngine = lazy(() => import("./app/policyEngine/components/AddPolicyEngine")); +const RecoveryIncidentsList = lazy(() => import("./app/recoveryIncidents/components/RecoveryIncidentsList")); +const RecoveryIncidentTabs = lazy(() => import("./app/recoveryIncidents/tabs/index")); +const ConfigurationPage = lazy(() => import("./app/configuration")); +const AuditLogsList = lazy(() => import("./app/auditLogs/components/AuditLogsList")); +const UsersPage = lazy(() => import("./app/users")); +const RolesPage = lazy(() => import("./app/roles")); function AppRoutes() { return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - - - + +
+
+ } + > + + {/* Public Login Route */} + } /> + + {/* Protected Application Routes */} + + + +
+
+ } + > + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + +
+
+ + } + /> +
+
); } diff --git a/src/app/api/ApiClient.ts b/src/app/api/ApiClient.ts index 959d4ed..1ea208f 100644 --- a/src/app/api/ApiClient.ts +++ b/src/app/api/ApiClient.ts @@ -1,24 +1,38 @@ -import axios from 'axios'; +import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'; -const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'; -// Create a configured Axios instance +// Create a configured Axios instance with credentials enabled for secure HTTP-only cookies export const ApiClient = axios.create({ baseURL: API_BASE, + withCredentials: true, headers: { 'Content-Type': 'application/json', 'X-Tenant-Id': 'demo-airline', }, }); +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (value?: any) => void; + reject: (reason?: any) => void; +}> = []; + +const processQueue = (error: any, token: any = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + // Request Interceptor ApiClient.interceptors.request.use( - (config) => { - // You can attach authorization tokens here if needed - // const token = localStorage.getItem('token'); - // if (token) { - // config.headers.Authorization = `Bearer ${token}`; - // } + (config: InternalAxiosRequestConfig) => { + // Cookies are automatically sent via withCredentials: true return config; }, (error) => { @@ -26,17 +40,53 @@ ApiClient.interceptors.request.use( } ); -// Response Interceptor +// Response Interceptor with automatic silent refresh ApiClient.interceptors.response.use( (response) => { - // Return just the data payload by default for convenience return response.data; }, - (error) => { - // Handle global API errors here (e.g., redirect on 401 Unauthorized) - if (error.response?.status === 401) { - console.error('Unauthorized access - perhaps redirect to login?'); + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + + const isAuthUrl = + originalRequest?.url?.includes('/auth/login') || + originalRequest?.url?.includes('/auth/refresh') || + originalRequest?.url?.includes('/auth/me'); + + if (error.response?.status === 401 && !originalRequest?._retry && !isAuthUrl) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then(() => ApiClient(originalRequest)) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + await axios.post( + `${API_BASE}/auth/refresh`, + {}, + { withCredentials: true } + ); + processQueue(null); + return ApiClient(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + // Only redirect if not already on the login page + if (!window.location.pathname.startsWith('/login')) { + window.location.href = '/login'; + } + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } } + return Promise.reject(error); } ); + +export default ApiClient; diff --git a/src/app/auditLogs/components/AuditLogsList.tsx b/src/app/auditLogs/components/AuditLogsList.tsx index 47a533b..2fab338 100644 --- a/src/app/auditLogs/components/AuditLogsList.tsx +++ b/src/app/auditLogs/components/AuditLogsList.tsx @@ -18,6 +18,7 @@ import type { AuditLog, AuditLogFilters } from "../AuditLogsTypes"; import { AUDIT_MODULE_LABELS, AUDIT_ACTION_VARIANTS } from "../AuditLogsTypes"; import { getAuditLogs } from "../AuditLogsApi"; import AuditLogDetail from "./AuditLogDetail"; +import Can from "../../../components/common/Can"; const PAGE_SIZE = 20; @@ -335,14 +336,16 @@ export default function AuditLogsList() { className="!border-[#1E8E3E] !bg-[#E6F4EA] !h-[40px] hover:!bg-[#E6F4EA]" /> - - - Export CSV - + + + + Export CSV + + } currentPage={currentPage} diff --git a/src/app/authentication/index.tsx b/src/app/authentication/index.tsx index 0be90de..3c0a7ef 100644 --- a/src/app/authentication/index.tsx +++ b/src/app/authentication/index.tsx @@ -1,10 +1,254 @@ -function AboutPage() { - return ( -
-

About Page

-

This is the about page. Add your about content here.

-
- ) -} +import React, { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { + Lock, + Mail, + Eye, + EyeOff, + ShieldCheck, + Plane, + KeyRound, + Sparkles, + ArrowRight, + AlertCircle, +} from 'lucide-react'; -export default AboutPage +export default function LoginPage() { + const { login, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // If already authenticated, redirect + React.useEffect(() => { + if (isAuthenticated) { + const from = (location.state as any)?.from?.pathname || '/'; + navigate(from, { replace: true }); + } + }, [isAuthenticated, navigate, location]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email || !password) { + setError('Please provide both email and password.'); + return; + } + + setError(null); + setLoading(true); + + try { + await login(email, password); + const from = (location.state as any)?.from?.pathname || '/'; + navigate(from, { replace: true }); + } catch (err: any) { + const msg = + err?.response?.data?.message || + err?.message || + 'Failed to authenticate. Please check your credentials.'; + setError(msg); + } finally { + setLoading(false); + } + }; + + const handleQuickFill = () => { + setEmail('admin@aeroresolve.com'); + setPassword('Password@123'); + setError(null); + }; + + return ( +
+ {/* Left / Hero Column */} +
+ {/* Background glow & radar circles */} +
+
+
+ + {/* Top Logo */} +
+
+ +
+
+

+ AERO RESOLVE + + Enterprise + +

+

+ Aviation Passenger Recovery & Compensation Intelligence +

+
+
+ + {/* Hero Central Content */} +
+
+ + + Secure Role-Based Access Control (RBAC) + +
+ +

+ Next-Generation Flight Disruption Orchestration +

+ +

+ Automate passenger segmentation, multi-jurisdiction regulatory compensation, goodwill + policies, and live manifest recovery simulations in real time. +

+ +
+
+ 99.98% + Uptime Reliability +
+
+ < 150ms + Policy Engine Evaluation +
+
+
+ + {/* Bottom Trust Footer */} +
+ Aero Resolve Operating System v1.0 + Aerospace High-Security Protocol +
+
+ + {/* Right / Login Form Column */} +
+
+ {/* Header Mobile / Title */} +
+
+
+ +
+ Aero Resolve +
+ +

+ Sign In to Terminal +

+

+ Enter your credentials to access operations management. +

+
+ + {/* Quick Demo Fill Helper */} +
+
+ +
+ Super Admin Demo Credentials + admin@aeroresolve.com +
+
+ +
+ + {/* Error Banner */} + {error && ( +
+ + {error} +
+ )} + + {/* Form */} +
+
+ +
+
+ +
+ setEmail(e.target.value)} + placeholder="name@aeroresolve.com" + className="w-full pl-10 pr-4 py-3 bg-slate-900/90 border border-slate-700/80 rounded-xl text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 transition-all" + /> +
+
+ +
+
+ + + Forgot Password? + +
+
+
+ +
+ setPassword(e.target.value)} + placeholder="••••••••••••" + className="w-full pl-10 pr-11 py-3 bg-slate-900/90 border border-slate-700/80 rounded-xl text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 transition-all" + /> + +
+
+ + +
+ + {/* Security badge footer */} +
+ + Encrypted with HTTP-Only Cookie Authentication +
+
+
+
+ ); +} diff --git a/src/app/cohartManage/components/cohartList.tsx b/src/app/cohartManage/components/cohartList.tsx index 4c14c91..d6c5075 100644 --- a/src/app/cohartManage/components/cohartList.tsx +++ b/src/app/cohartManage/components/cohartList.tsx @@ -27,6 +27,7 @@ import { updateCohartStatus, } from "../CohartManageApi"; import type { CohartResponse } from "../CohartManageTypes"; +import Can from "../../../components/common/Can"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -191,35 +192,43 @@ export default function CohortList() { accessor: (row) => ( {row.status !== "Active" && ( - } - variant="success" - onClick={() => setDeactivateTarget(row)} - > - Activate - + + } + variant="success" + onClick={() => setDeactivateTarget(row)} + > + Activate + + )} {row.status === "Active" && ( - } - onClick={() => setDeactivateTarget(row)} - > - Deactivate - + + } + onClick={() => setDeactivateTarget(row)} + > + Deactivate + + )} - } - onClick={() => setEditTarget(row)} - > - Edit - - } - variant="danger" - onClick={() => setDeleteTarget(row)} - > - Delete - + + } + onClick={() => setEditTarget(row)} + > + Edit + + + + } + variant="danger" + onClick={() => setDeleteTarget(row)} + > + Delete + + ), }, @@ -291,15 +300,17 @@ export default function CohortList() { size="md" />
- } - className="!rounded-[10px] !gap-[10px] !h-[40px]" - onClick={() => setIsAddOpen(true)} - > - Create Cohort - + + } + className="!rounded-[10px] !gap-[10px] !h-[40px]" + onClick={() => setIsAddOpen(true)} + > + Create Cohort + + } currentPage={currentPage} diff --git a/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx b/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx index 9eb4e07..d6850b6 100644 --- a/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx +++ b/src/app/configuration/actionBuilder/components/ActionTypesColumn.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react'; import { CustomInput, CustomButton } from '../../../../components/custom'; import type { ActionType } from '../ActionBuilderTypes'; +import Can from '../../../../components/common/Can'; interface ActionTypesColumnProps { actionTypes: ActionType[]; @@ -40,15 +41,17 @@ export function ActionTypesColumn({ {/* Column Header */}

Action Types

- } - > - Add - + + } + > + Add + +
@@ -114,24 +117,28 @@ export function ActionTypesColumn({
{/* Hover Actions */}
- - + + + + + +
{/* Count Badge */} diff --git a/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx b/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx index 3c39b64..84ac5a5 100644 --- a/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx +++ b/src/app/configuration/actionBuilder/components/CategoriesColumn.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react'; import { CustomInput, CustomButton } from '../../../../components/custom'; import type { ActionCategory, ActionType } from '../ActionBuilderTypes'; +import Can from '../../../../components/common/Can'; interface CategoriesColumnProps { categories: ActionCategory[]; @@ -36,14 +37,16 @@ export function CategoriesColumn({ {/* Column Header */}

Categories

- } - > - Add - + + } + > + Add + +
@@ -104,24 +107,28 @@ export function CategoriesColumn({
{/* Hover Actions */}
- - + + + + + +
{/* Count Badge */} diff --git a/src/app/configuration/actionBuilder/components/ConfigurationFieldsColumn.tsx b/src/app/configuration/actionBuilder/components/ConfigurationFieldsColumn.tsx index 4b2bb2c..aaad842 100644 --- a/src/app/configuration/actionBuilder/components/ConfigurationFieldsColumn.tsx +++ b/src/app/configuration/actionBuilder/components/ConfigurationFieldsColumn.tsx @@ -7,8 +7,9 @@ import { TrashIcon, DotsSixVerticalIcon, } from '@phosphor-icons/react'; -import { CustomInput, CustomButton, Skeleton } from '../../../../components/custom'; import type { FieldDefinition } from '../ActionBuilderTypes'; +import Can from '../../../../components/common/Can'; +import { CustomButton, CustomInput, Skeleton } from '../../../../components/custom'; interface ConfigurationFieldsColumnProps { fields: FieldDefinition[]; @@ -47,15 +48,17 @@ export function ConfigurationFieldsColumn({ {/* Column Header */}

Configuration Field

- } - > - Add - + + } + > + Add + +
{/* Search Input */} @@ -184,22 +187,27 @@ export function ConfigurationFieldsColumn({ | {/* Action Buttons */} - - + + + + + + +
diff --git a/src/app/configuration/masterData/components/MasterItemTable.tsx b/src/app/configuration/masterData/components/MasterItemTable.tsx index ac7ab20..6a5e76e 100644 --- a/src/app/configuration/masterData/components/MasterItemTable.tsx +++ b/src/app/configuration/masterData/components/MasterItemTable.tsx @@ -14,6 +14,7 @@ import { CustomStatus, } from '../../../../components/custom'; import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes'; +import Can from '../../../../components/common/Can'; interface MasterItemTableProps { selectedCategory: MasterDataCategoryItem | null; @@ -63,15 +64,17 @@ export const MasterItemTable: React.FC = ({

- } - className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] !gap-[8px] !h-[40px] font-semibold text-[14px] shrink-0" - > - Add Master Value - + + } + className="!bg-[#1E7D5C] hover:!bg-[#17664B] !rounded-[12px] !gap-[8px] !h-[40px] font-semibold text-[14px] shrink-0" + > + Add Master Value + +
{/* Search Bar */} @@ -155,21 +158,25 @@ export const MasterItemTable: React.FC = ({
- + + + - + + +
diff --git a/src/app/dashboard/index.tsx b/src/app/dashboard/index.tsx index 5a83efa..2dd9bd6 100644 --- a/src/app/dashboard/index.tsx +++ b/src/app/dashboard/index.tsx @@ -30,12 +30,13 @@ export default function HomePage() { async function loadDashboardData() { try { setLoading(true); - const [metricsRes, exposureRes, mixRes, incidentsRes] = await Promise.all([ - getDashboardMetrics(), - getExposureData(), - getDisruptionMixData(), - getRecentIncidents(), - ]); + const [metricsRes, exposureRes, mixRes, incidentsRes] = + await Promise.all([ + getDashboardMetrics(), + getExposureData(), + getDisruptionMixData(), + getRecentIncidents(), + ]); setMetrics(metricsRes); setExposureData(exposureRes); @@ -57,7 +58,10 @@ export default function HomePage() { {/* Top Stat Cards Skeleton */}
{Array.from({ length: 5 }).map((_, i) => ( -
+
diff --git a/src/app/policyEngine/components/AddPolicyEngine.tsx b/src/app/policyEngine/components/AddPolicyEngine.tsx index 2d92e59..7a9918a 100644 --- a/src/app/policyEngine/components/AddPolicyEngine.tsx +++ b/src/app/policyEngine/components/AddPolicyEngine.tsx @@ -18,6 +18,7 @@ import { Skeleton, } from '../../../components/custom'; import CustomSuccessModal from '../../../components/custom/CustomSuccessModal'; +import Can from '../../../components/common/Can'; import { getJurisdictionOptions, getCohortOptions, @@ -1648,14 +1649,16 @@ export default function AddPolicyEngine() {
- handleSavePolicy(false)} - disabled={isSaving || !isDraftValid} - > - {isSaving ? 'Saving...' : 'Save Draft'} - + + handleSavePolicy(false)} + disabled={isSaving || !isDraftValid} + > + {isSaving ? 'Saving...' : 'Save Draft'} + + Cancel Policy - handleSavePolicy(true)} - disabled={isSaving || !isFormValid} - > - {isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'} - + + handleSavePolicy(true)} + disabled={isSaving || !isFormValid} + > + {isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'} + +
diff --git a/src/app/policyEngine/components/PolicyEngineList.tsx b/src/app/policyEngine/components/PolicyEngineList.tsx index 30f2d24..3476123 100644 --- a/src/app/policyEngine/components/PolicyEngineList.tsx +++ b/src/app/policyEngine/components/PolicyEngineList.tsx @@ -21,6 +21,7 @@ import { } from "../../../components/custom"; import type { Column } from "../../../components/custom/CustomTable"; import type { PolicyEngineResponse } from "../PolicyEngineTypes"; +import Can from "../../../components/common/Can"; import { getPolicies, deletePolicy, @@ -201,35 +202,43 @@ export default function PolicyEngineList() { accessor: (row) => ( {row.status?.toLowerCase() === "inactive" && ( - } - variant="success" - onClick={() => setDeactivateTarget(row)} - > - Activate - + + } + variant="success" + onClick={() => setDeactivateTarget(row)} + > + Activate + + )} {row.status?.toLowerCase() === "active" && ( - } - onClick={() => setDeactivateTarget(row)} - > - Deactivate - + + } + onClick={() => setDeactivateTarget(row)} + > + Deactivate + + )} - } - onClick={() => navigate(`/policy-engine/add?id=${row.id}`)} - > - Edit - - } - variant="danger" - onClick={() => setDeleteTarget(row)} - > - Delete - + + } + onClick={() => navigate(`/policy-engine/add?id=${row.id}`)} + > + Edit + + + + } + variant="danger" + onClick={() => setDeleteTarget(row)} + > + Delete + + ), }, @@ -290,15 +299,17 @@ export default function PolicyEngineList() { } rightHeaderActions={ <> - } - className="!rounded-[10px] !gap-[10px] !h-[40px] !bg-[#1E7D5C] hover:!bg-[#17664B]" - onClick={() => navigate("/policy-engine/add")} - > - Deploy New Policy - + + } + className="!rounded-[10px] !gap-[10px] !h-[40px] !bg-[#1E7D5C] hover:!bg-[#17664B]" + onClick={() => navigate("/policy-engine/add")} + > + Deploy New Policy + + } currentPage={currentPage} diff --git a/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx b/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx index e7f70b7..59da888 100644 --- a/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx +++ b/src/app/recoveryIncidents/components/RecoveryIncidentsList.tsx @@ -25,6 +25,7 @@ import { CustomAlertBanner, CustomSuccessModal, } from "../../../components/custom"; +import Can from "../../../components/common/Can"; import type { Column } from "../../../components/custom/CustomTable"; import type { RecoveryIncident, @@ -479,38 +480,46 @@ export default function RecoveryIncidentsList() { > View Details - { - setEditingIncident(row); - setIsModalOpen(true); - }} - icon={ - - } - > - Edit Incident - - - } - onClick={() => handleStatusChange(row, "Approved")} - > - Approve - - } - onClick={() => handleStatusChange(row, "Rejected")} - > - Reject - - } - onClick={() => handleStatusChange(row, "Under Review")} - > - Mark for Review - + + { + setEditingIncident(row); + setIsModalOpen(true); + }} + icon={ + + } + > + Edit Incident + + + + + } + onClick={() => handleStatusChange(row, "Approved")} + > + Approve + + + + } + onClick={() => handleStatusChange(row, "Rejected")} + > + Reject + + + + } + onClick={() => handleStatusChange(row, "Under Review")} + > + Mark for Review + + )}
@@ -618,18 +627,20 @@ export default function RecoveryIncidentsList() { > {isGrouped ? "Ungroup" : "Group by Flight"} - } - className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" - onClick={() => { - setEditingIncident(null); - setIsModalOpen(true); - }} - > - New Incident - + + } + className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" + onClick={() => { + setEditingIncident(null); + setIsModalOpen(true); + }} + > + New Incident + + } currentPage={currentPage} diff --git a/src/app/roles/RolesApi.ts b/src/app/roles/RolesApi.ts new file mode 100644 index 0000000..95f1324 --- /dev/null +++ b/src/app/roles/RolesApi.ts @@ -0,0 +1,69 @@ +import { ApiClient } from '../api/ApiClient'; + +export interface PermissionItem { + id: string; + module: string; + action: string; + code: string; + name: string; + description?: string; + groupName: string; +} + +export interface RoleItem { + id: string; + name: string; + slug: string; + description?: string; + isSystem: boolean; + permissionCount: number; + userCount: number; + permissions?: PermissionItem[]; + createdAt: string; + updatedAt: string; +} + +export interface PermissionsResponse { + all: PermissionItem[]; + grouped: Record; +} + +export interface CreateRolePayload { + name: string; + description?: string; + permissionIds: string[]; +} + +export interface UpdateRolePayload { + name?: string; + description?: string; + permissionIds?: string[]; +} + +export const RolesApi = { + getRoles: async (): Promise => { + return ApiClient.get('/roles'); + }, + + getRoleById: async (id: string): Promise => { + return ApiClient.get(`/roles/${id}`); + }, + + getPermissions: async (): Promise => { + return ApiClient.get('/permissions'); + }, + + createRole: async (payload: CreateRolePayload): Promise => { + return ApiClient.post('/roles', payload); + }, + + updateRole: async (id: string, payload: UpdateRolePayload): Promise => { + return ApiClient.patch(`/roles/${id}`, payload); + }, + + deleteRole: async (id: string): Promise<{ success: boolean; message: string }> => { + return ApiClient.delete(`/roles/${id}`); + }, +}; + +export default RolesApi; diff --git a/src/app/roles/components/AddRoles.tsx b/src/app/roles/components/AddRoles.tsx new file mode 100644 index 0000000..3ce8ea6 --- /dev/null +++ b/src/app/roles/components/AddRoles.tsx @@ -0,0 +1,355 @@ +import React, { useEffect, useState } from 'react'; +import { RolesApi, type PermissionItem, type RoleItem } from '../RolesApi'; +import { + CustomInput, + CustomButton, + CustomCheckBox, + CustomAlertBanner, + Skeleton, +} from '../../../components/custom'; +import { + ShieldCheckIcon, + LockKeyIcon, + ArrowLeftIcon, + FloppyDiskIcon, + SquaresFourIcon, +} from '@phosphor-icons/react'; + +interface AddRolesProps { + roleToEdit?: RoleItem | null; + onBack: () => void; + onSaved: () => void; +} + +export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedPermissionIds, setSelectedPermissionIds] = useState>(new Set()); + const [groupedPermissions, setGroupedPermissions] = useState>({}); + const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(true); + const [error, setError] = useState(null); + + const isSystemRole = roleToEdit?.isSystem ?? false; + + useEffect(() => { + loadPermissions(); + }, [roleToEdit]); + + const loadPermissions = async () => { + setFetching(true); + setError(null); + try { + const response = await RolesApi.getPermissions(); + setGroupedPermissions(response.grouped || {}); + + if (roleToEdit) { + setName(roleToEdit.name); + setDescription(roleToEdit.description || ''); + + // Fetch full role details to get its permissions + const detailedRole = await RolesApi.getRoleById(roleToEdit.id); + const existingIds = (detailedRole.permissions || []).map((p) => p.id); + setSelectedPermissionIds(new Set(existingIds)); + } else { + setName(''); + setDescription(''); + setSelectedPermissionIds(new Set()); + } + } catch (err: any) { + setError(err?.response?.data?.message || 'Failed to load permissions list.'); + } finally { + setFetching(false); + } + }; + + const togglePermission = (id: string) => { + if (isSystemRole) return; + setSelectedPermissionIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const toggleGroup = (_groupName: string, perms: PermissionItem[]) => { + if (isSystemRole) return; + const groupIds = perms.map((p) => p.id); + const allSelected = groupIds.every((id) => selectedPermissionIds.has(id)); + + setSelectedPermissionIds((prev) => { + const next = new Set(prev); + if (allSelected) { + groupIds.forEach((id) => next.delete(id)); + } else { + groupIds.forEach((id) => next.add(id)); + } + return next; + }); + }; + + const selectAll = () => { + if (isSystemRole) return; + const allIds = Object.values(groupedPermissions).flatMap((perms) => perms.map((p) => p.id)); + setSelectedPermissionIds(new Set(allIds)); + }; + + const deselectAll = () => { + if (isSystemRole) return; + setSelectedPermissionIds(new Set()); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + setError('Role name is required.'); + return; + } + + if (selectedPermissionIds.size === 0) { + setError('Please select at least one permission for this role.'); + return; + } + + setLoading(true); + setError(null); + + try { + const permissionIds = Array.from(selectedPermissionIds); + if (roleToEdit) { + await RolesApi.updateRole(roleToEdit.id, { + name: name.trim(), + description: description.trim(), + permissionIds, + }); + } else { + await RolesApi.createRole({ + name: name.trim(), + description: description.trim(), + permissionIds, + }); + } + onSaved(); + } catch (err: any) { + setError(err?.response?.data?.message || 'Failed to save role.'); + } finally { + setLoading(false); + } + }; + + if (fetching) { + return ( +
+ + + +
+ ); + } + + return ( +
+ {/* Header bar */} +
+
+ } + onClick={onBack} + className="!rounded-[10px] !h-[38px]" + > + Back + +
+

+ {roleToEdit ? `Edit Role: ${roleToEdit.name}` : 'Create Custom Role'} + {isSystemRole && ( + + System Protected + + )} +

+

+ Configure role metadata and assign module-specific action permissions. +

+
+
+ + {!isSystemRole && ( +
+ + Select All + + + Deselect All + +
+ )} +
+ + {/* Error Alert */} + {error && ( + setError(null)} + /> + )} + +
+ {/* Role General Info */} +
+

+ + General Information +

+ +
+ setName(e.target.value)} + placeholder="e.g. Flight Disruption Lead" + className="!h-[42px] !rounded-[10px]" + /> + + setDescription(e.target.value)} + placeholder="e.g. Manages passenger goodwill offers and compensation evaluations" + className="!h-[42px] !rounded-[10px]" + /> +
+
+ + {/* Permission Matrix */} +
+
+
+

+ + Granular Permissions Matrix +

+

+ Selected {selectedPermissionIds.size} permission(s) +

+
+
+ +
+ {Object.entries(groupedPermissions).map(([groupName, perms]) => { + const groupIds = perms.map((p) => p.id); + const allGroupSelected = groupIds.every((id) => selectedPermissionIds.has(id)); + + return ( +
+ {/* Module header */} +
+
+ toggleGroup(groupName, perms)} + /> + {groupName} +
+ + {perms.filter((p) => selectedPermissionIds.has(p.id)).length} / {perms.length} selected + +
+ + {/* Permission cards grid */} +
+ {perms.map((perm) => { + const isSelected = selectedPermissionIds.has(perm.id); + return ( +
togglePermission(perm.id)} + className={`p-3 rounded-[10px] border transition-all cursor-pointer select-none flex items-start gap-3 ${ + isSelected + ? 'bg-emerald-50/70 border-emerald-300' + : 'bg-white border-gray-200 hover:border-gray-300' + } ${isSystemRole ? 'opacity-80 cursor-default' : ''}`} + > +
+ togglePermission(perm.id)} + onClick={(e) => e.stopPropagation()} + /> +
+
+ + {perm.name} + + + {perm.code} + + {perm.description && ( +

+ {perm.description} +

+ )} +
+
+ ); + })} +
+
+ ); + })} +
+
+ + {/* Footer save buttons */} +
+ + Cancel + + {!isSystemRole && ( + } + loading={loading} + disabled={loading} + onClick={handleSubmit} + className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" + > + {roleToEdit ? 'Save Changes' : 'Create Role'} + + )} +
+
+
+ ); +} diff --git a/src/app/roles/components/RolesList.tsx b/src/app/roles/components/RolesList.tsx new file mode 100644 index 0000000..3395581 --- /dev/null +++ b/src/app/roles/components/RolesList.tsx @@ -0,0 +1,263 @@ +import { useEffect, useState, useMemo } from 'react'; +import { RolesApi, type RoleItem } from '../RolesApi'; +import { + CustomInput, + CustomButton, + CustomAlertBanner, + CustomConfirmationModal, + Skeleton, +} from '../../../components/custom'; +import { + ShieldCheckIcon, + PlusIcon, + LockKeyIcon, + UsersIcon, + KeyIcon, + PencilSimpleIcon, + TrashIcon, + MagnifyingGlassIcon, +} from '@phosphor-icons/react'; +import Can from '../../../components/common/Can'; + +interface RolesListProps { + onAddRole: () => void; + onEditRole: (role: RoleItem) => void; +} + +export default function RolesList({ onAddRole, onEditRole }: RolesListProps) { + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [deleteConfirmRole, setDeleteConfirmRole] = useState(null); + const [deleting, setDeleting] = useState(false); + const [successMsg, setSuccessMsg] = useState(null); + + useEffect(() => { + loadRoles(); + }, []); + + const loadRoles = async () => { + setLoading(true); + setError(null); + try { + const data = await RolesApi.getRoles(); + setRoles(data); + } catch (err: any) { + setError(err?.response?.data?.message || 'Failed to load roles'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (!deleteConfirmRole) return; + setDeleting(true); + try { + await RolesApi.deleteRole(deleteConfirmRole.id); + setSuccessMsg(`Role "${deleteConfirmRole.name}" was deleted successfully.`); + setDeleteConfirmRole(null); + loadRoles(); + } catch (err: any) { + setError(err?.response?.data?.message || 'Failed to delete role.'); + } finally { + setDeleting(false); + } + }; + + const filteredRoles = useMemo(() => { + const q = search.toLowerCase().trim(); + if (!q) return roles; + return roles.filter( + (r) => + r.name.toLowerCase().includes(q) || + r.slug.toLowerCase().includes(q) || + (r.description || '').toLowerCase().includes(q) + ); + }, [roles, search]); + + if (loading) { + return ( +
+
+ +
+ +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ); + } + + return ( +
+ {error && ( + setError(null)} + /> + )} + {successMsg && ( + setSuccessMsg(null)} + /> + )} + + {/* Top Action Bar */} +
+
+ setSearch(e.target.value)} + leftIcon={} + className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]" + containerClassName="!gap-0" + /> +
+ + + } + className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" + onClick={onAddRole} + > + Create Role + + +
+ + {/* Roles Cards Grid */} +
+ {filteredRoles.map((role) => ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ {role.name} +

+ + {role.slug} + +
+
+ + {role.isSystem ? ( + + System Default + + ) : ( + + Custom + + )} +
+ + {/* Description */} +

+ {role.description || 'No description configured for this operational role.'} +

+ + {/* Metrics */} +
+
+ +
+ + Permissions + + + {role.permissionCount} rules + +
+
+ +
+ +
+ + Users + + + {role.userCount} assigned + +
+
+
+
+ + {/* Action Buttons */} +
+ + } + onClick={() => onEditRole(role)} + className="!rounded-[8px] !h-[34px]" + > + {role.isSystem ? 'View Permissions' : 'Edit Role'} + + + + {!role.isSystem && ( + + } + onClick={() => setDeleteConfirmRole(role)} + className="!rounded-[8px] !h-[34px] !border-red-200 !text-red-600 hover:!bg-red-50" + > + Delete + + + )} +
+
+ ))} +
+ + {/* Delete Confirmation Modal */} + setDeleteConfirmRole(null)} + onConfirm={handleDelete} + title="Delete Role" + description={`Are you sure you want to permanently delete the role "${deleteConfirmRole?.name}"? ${ + deleteConfirmRole && deleteConfirmRole.userCount > 0 + ? `Warning: ${deleteConfirmRole.userCount} user(s) are currently assigned to this role.` + : '' + }`} + confirmText={deleting ? 'Deleting...' : 'Confirm Delete'} + variant="danger" + isLoading={deleting} + /> +
+ ); +} diff --git a/src/app/roles/index.tsx b/src/app/roles/index.tsx new file mode 100644 index 0000000..2cb9333 --- /dev/null +++ b/src/app/roles/index.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import RolesList from './components/RolesList'; +import AddRoles from './components/AddRoles'; +import type { RoleItem } from './RolesApi'; + +export default function RolesManagementPage() { + const [view, setView] = useState<'list' | 'add' | 'edit'>('list'); + const [selectedRole, setSelectedRole] = useState(null); + + const handleAddRole = () => { + setSelectedRole(null); + setView('add'); + }; + + const handleEditRole = (role: RoleItem) => { + setSelectedRole(role); + setView('edit'); + }; + + const handleBack = () => { + setSelectedRole(null); + setView('list'); + }; + + const handleSaved = () => { + setSelectedRole(null); + setView('list'); + }; + + return ( +
+ {view === 'list' && ( + + )} + {(view === 'add' || view === 'edit') && ( + + )} +
+ ); +} diff --git a/src/app/simulation/components/SimulationTerminal.tsx b/src/app/simulation/components/SimulationTerminal.tsx index a14e07f..e980dad 100644 --- a/src/app/simulation/components/SimulationTerminal.tsx +++ b/src/app/simulation/components/SimulationTerminal.tsx @@ -21,6 +21,7 @@ import type { SimulatedPassenger, SimulationFormState } from '../SimulationTypes import { createRecoveryIncident } from '../../recoveryIncidents/RecoveryIncidentsApi'; import { getCategoryValues } from '../../configuration/masterData/MasterDataApi'; import { evaluateBatchSimulation } from '../SimulationApi'; +import Can from '../../../components/common/Can'; import { CATEGORY_SIMULATION_CONFIGS, type CategorySimulationConfig, @@ -507,14 +508,20 @@ export default function SimulationTerminal() { {/* Action Button using CustomButton */}
- - Run Manifest Simulation - + + Execution permission (simulation:execute) required to run simulations. +
+ }> + + Run Manifest Simulation + +
@@ -562,17 +569,19 @@ export default function SimulationTerminal() { /> - } - onClick={handleSaveAsRecovery} - className="!border-[#1B9869] !text-[#1B9869] hover:!bg-[#1B9869]/5 !rounded-xl !px-4 !py-2 shrink-0 font-semibold text-xs" - > - Save as Recovery - + + } + onClick={handleSaveAsRecovery} + className="!border-[#1B9869] !text-[#1B9869] hover:!bg-[#1B9869]/5 !rounded-xl !px-4 !py-2 shrink-0 font-semibold text-xs" + > + Save as Recovery + + {/* Dynamic Manifest Table */} diff --git a/src/app/users/UsersApi.ts b/src/app/users/UsersApi.ts new file mode 100644 index 0000000..fb474a9 --- /dev/null +++ b/src/app/users/UsersApi.ts @@ -0,0 +1,67 @@ +import { ApiClient } from '../api/ApiClient'; +import type { UserRole } from '../../context/AuthContext'; + +export interface UserItem { + id: string; + email: string; + firstName: string; + lastName?: string; + fullName: string; + roleId: string; + role: UserRole; + tenantId?: string | null; + isActive: boolean; + isSystem: boolean; + permissions: string[]; + lastLoginAt?: string | null; + createdAt: string; + updatedAt?: string; +} + +export interface CreateUserPayload { + email: string; + password: string; + firstName: string; + lastName?: string; + roleId: string; + isActive?: boolean; +} + +export interface UpdateUserPayload { + firstName?: string; + lastName?: string; + roleId?: string; + isActive?: boolean; +} + +export const UsersApi = { + getUsers: async (search?: string, roleId?: string): Promise => { + return ApiClient.get('/users', { + params: { search, roleId }, + }); + }, + + getUserById: async (id: string): Promise => { + return ApiClient.get(`/users/${id}`); + }, + + createUser: async (payload: CreateUserPayload): Promise => { + return ApiClient.post('/users', payload); + }, + + updateUser: async (id: string, payload: UpdateUserPayload): Promise => { + return ApiClient.patch(`/users/${id}`, payload); + }, + + resetPassword: async (id: string, newPassword: string): Promise<{ success: boolean; message: string }> => { + return ApiClient.post(`/users/${id}/reset-password`, { + newPassword, + }); + }, + + deleteUser: async (id: string): Promise<{ success: boolean; message: string }> => { + return ApiClient.delete(`/users/${id}`); + }, +}; + +export default UsersApi; diff --git a/src/app/users/components/ResetPasswordModal.tsx b/src/app/users/components/ResetPasswordModal.tsx new file mode 100644 index 0000000..f469068 --- /dev/null +++ b/src/app/users/components/ResetPasswordModal.tsx @@ -0,0 +1,110 @@ +import React, { useState } from 'react'; +import { UsersApi, type UserItem } from '../UsersApi'; +import { + CustomModal, + CustomInput, + CustomButton, + CustomAlertBanner, +} from '../../../components/custom'; +import { LockKeyIcon, KeyIcon } from '@phosphor-icons/react'; + +interface ResetPasswordModalProps { + user: UserItem | null; + isOpen: boolean; + onClose: () => void; + onSuccess: (email: string) => void; +} + +export default function ResetPasswordModal({ + user, + isOpen, + onClose, + onSuccess, +}: ResetPasswordModalProps) { + const [newPassword, setNewPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + if (!isOpen || !user) return null; + + const handleSubmit = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!newPassword || newPassword.length < 8) { + setError('New password must be at least 8 characters long.'); + return; + } + + setLoading(true); + setError(null); + + try { + await UsersApi.resetPassword(user.id, newPassword); + onSuccess(user.email); + onClose(); + } catch (err: any) { + setError(err?.response?.data?.message || 'Failed to reset password.'); + } finally { + setLoading(false); + } + }; + + return ( + + + Cancel + + } + loading={loading} + disabled={loading} + onClick={handleSubmit} + className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" + > + Update Password + + + } + > +
+ {error && ( + setError(null)} + /> + )} + +
+ Setting a new password will immediately revoke active tokens and require the user to sign in with the new credentials. +
+ +
+ setNewPassword(e.target.value)} + leftIcon={} + className="!h-[42px] !rounded-[10px]" + /> + +
+
+ ); +} diff --git a/src/app/users/components/UserModal.tsx b/src/app/users/components/UserModal.tsx new file mode 100644 index 0000000..f36f2a8 --- /dev/null +++ b/src/app/users/components/UserModal.tsx @@ -0,0 +1,264 @@ +import React, { useEffect, useState } from 'react'; +import { UsersApi, type UserItem } from '../UsersApi'; +import { RolesApi, type RoleItem } from '../../roles/RolesApi'; +import { + CustomModal, + CustomInput, + CustomDropdown, + CustomSwitch, + CustomButton, + CustomAlertBanner, +} from '../../../components/custom'; +import { + UserIcon, + EnvelopeSimpleIcon, + LockKeyIcon, + ShieldCheckIcon, + FloppyDiskIcon, +} from '@phosphor-icons/react'; + +interface UserModalProps { + userToEdit?: UserItem | null; + isOpen: boolean; + onClose: () => void; + onSaved: () => void; +} + +export default function UserModal({ + userToEdit, + isOpen, + onClose, + onSaved, +}: UserModalProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [roleId, setRoleId] = useState(''); + const [isActive, setIsActive] = useState(true); + + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + loadRoles(); + if (userToEdit) { + setEmail(userToEdit.email); + setFirstName(userToEdit.firstName); + setLastName(userToEdit.lastName || ''); + setRoleId(userToEdit.roleId); + setIsActive(userToEdit.isActive); + setPassword(''); + } else { + setEmail(''); + setPassword(''); + setFirstName(''); + setLastName(''); + setRoleId(''); + setIsActive(true); + } + setError(null); + } + }, [isOpen, userToEdit]); + + const loadRoles = async () => { + try { + const data = await RolesApi.getRoles(); + setRoles(data); + if (!userToEdit && data.length > 0 && !roleId) { + setRoleId(data[0].id); + } + } catch (e) { + console.error('Failed to load roles in user modal:', e); + } + }; + + const isSystemAdmin = userToEdit?.isSystem ?? false; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!firstName.trim()) { + setError('First name is required.'); + return; + } + + if (!userToEdit && !email.trim()) { + setError('Email address is required.'); + return; + } + + if (!userToEdit && (!password || password.length < 8)) { + setError('Password must be at least 8 characters long.'); + return; + } + + if (!roleId) { + setError('Please assign an operational role.'); + return; + } + + setLoading(true); + + try { + if (userToEdit) { + await UsersApi.updateUser(userToEdit.id, { + firstName: firstName.trim(), + lastName: lastName.trim() || undefined, + roleId, + isActive, + }); + } else { + await UsersApi.createUser({ + email: email.trim().toLowerCase(), + password, + firstName: firstName.trim(), + lastName: lastName.trim() || undefined, + roleId, + isActive, + }); + } + onSaved(); + } catch (err: any) { + setError(err?.response?.data?.message || 'Failed to save user account.'); + } finally { + setLoading(false); + } + }; + + const roleOptions = roles.map((r) => ({ + label: `${r.name}${r.isSystem ? ' (System Default)' : ''}`, + value: r.id, + })); + + return ( + + + Cancel + + } + loading={loading} + disabled={loading} + onClick={handleSubmit} + className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" + > + {userToEdit ? 'Save Changes' : 'Provision User'} + + + } + > +
+ {error && ( + setError(null)} + /> + )} + +
+
+ setFirstName(e.target.value)} + leftIcon={} + className="!h-[42px] !rounded-[10px]" + /> + + setLastName(e.target.value)} + leftIcon={} + className="!h-[42px] !rounded-[10px]" + /> +
+ + setEmail(e.target.value)} + leftIcon={} + className="!h-[42px] !rounded-[10px]" + /> + + {!userToEdit && ( + setPassword(e.target.value)} + leftIcon={} + className="!h-[42px] !rounded-[10px]" + /> + )} + +
+ setRoleId(val)} + placeholder="Select Operational Role..." + leftIcon={} + size="md" + className="!h-[42px] !rounded-[10px]" + /> +
+ +
+
+ + Account Status + + + {isActive + ? 'User can authenticate and access authorized workflows.' + : 'Account is deactivated and prohibited from signing in.'} + +
+ setIsActive(e.target.checked)} + /> +
+ +
+
+ ); +} diff --git a/src/app/users/components/UsersList.tsx b/src/app/users/components/UsersList.tsx new file mode 100644 index 0000000..3c92652 --- /dev/null +++ b/src/app/users/components/UsersList.tsx @@ -0,0 +1,407 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { UsersApi, type UserItem } from '../UsersApi'; +import { + CustomTable, + CustomInput, + CustomButton, + CustomStatus, + CustomCheckBox, + Skeleton, + CustomActionMenu, + CustomActionItem, + CustomAlertBanner, + CustomConfirmationModal, +} from '../../../components/custom'; +import type { Column } from '../../../components/custom/CustomTable'; +import { + PencilSimpleIcon, + TrashIcon, + KeyIcon, + PlusIcon, + MagnifyingGlassIcon, + LockKeyIcon, +} from '@phosphor-icons/react'; +import UserModal from './UserModal'; +import ResetPasswordModal from './ResetPasswordModal'; +import Can from '../../../components/common/Can'; +import { formatDate } from '../../../utils/formatDate'; + +const PAGE_SIZE = 10; + +function HeaderLabel({ + text, + rightIcon, +}: { + text: string; + rightIcon?: React.ReactNode; +}) { + return ( +
+ + {text} + + {rightIcon && {rightIcon}} +
+ ); +} + +function PrimaryText({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} + +function SecondaryText({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} + +function BadgeLabel({ text }: { text: string }) { + return ( + + {text} + + ); +} + +export default function UsersList() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // Pagination & Search + const [currentPage, setCurrentPage] = useState(1); + const [search, setSearch] = useState(''); + + // Selection + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Modals + const [userModalOpen, setUserModalOpen] = useState(false); + const [userToEdit, setUserToEdit] = useState(null); + + const [resetModalOpen, setResetModalOpen] = useState(false); + const [userForReset, setUserForReset] = useState(null); + + const [deleteConfirmUser, setDeleteConfirmUser] = useState(null); + const [deleting, setDeleting] = useState(false); + + const fetchUsers = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await UsersApi.getUsers(); + setUsers(data); + } catch (err: any) { + console.error('Failed to load users:', err); + setError(err?.response?.data?.message || 'Failed to load user accounts.'); + setUsers([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + // Handlers + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const handleSearchChange = (val: string) => { + setSearch(val); + setCurrentPage(1); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === users.length && users.length > 0) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(users.map((u) => u.id))); + } + }; + + const toggleSelectOne = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setSelectedIds(next); + }; + + const handleCreateUser = () => { + setUserToEdit(null); + setUserModalOpen(true); + }; + + const handleEditUser = (user: UserItem) => { + setUserToEdit(user); + setUserModalOpen(true); + }; + + const handleResetPassword = (user: UserItem) => { + setUserForReset(user); + setResetModalOpen(true); + }; + + const handleDelete = async () => { + if (!deleteConfirmUser) return; + setDeleting(true); + try { + await UsersApi.deleteUser(deleteConfirmUser.id); + setSuccessMsg(`User "${deleteConfirmUser.email}" was removed successfully.`); + setDeleteConfirmUser(null); + fetchUsers(); + } catch (err: any) { + setError(err?.response?.data?.message || 'Failed to delete user.'); + } finally { + setDeleting(false); + } + }; + + const handleSaved = () => { + setUserModalOpen(false); + setSuccessMsg('User account updated successfully.'); + fetchUsers(); + }; + + const handleResetSuccess = (email: string) => { + setSuccessMsg(`Password for ${email} was reset successfully.`); + }; + + // Filtered & Paginated Data + const filteredUsers = useMemo(() => { + const q = search.toLowerCase().trim(); + if (!q) return users; + return users.filter( + (u) => + u.fullName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + (u.role?.name || '').toLowerCase().includes(q) + ); + }, [users, search]); + + const totalItems = filteredUsers.length; + const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1; + const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0; + const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems); + + const paginatedUsers = useMemo(() => { + const start = (currentPage - 1) * PAGE_SIZE; + return filteredUsers.slice(start, start + PAGE_SIZE); + }, [filteredUsers, currentPage]); + + // Columns definition + const columns: Column[] = [ + { + header: ( + 0 && selectedIds.size === users.length} + onChange={toggleSelectAll} + /> + ), + className: 'w-[40px] pr-0', + accessor: (row) => ( + toggleSelectOne(row.id)} + onClick={(e) => e.stopPropagation()} + /> + ), + }, + { + header: , + accessor: (row) => ( +
+
+ + {row.isSystem && ( + + + System Admin + + )} +
+ +
+ ), + }, + { + header: , + accessor: (row) => , + }, + { + header: , + accessor: (row) => ( + + ), + }, + { + header: , + accessor: (row) => ( + + ), + }, + { + header: , + accessor: (row) => , + }, + { + header: , + accessor: (row) => ( +
+ + + handleEditUser(row)} + icon={} + > + Edit User + + + + + handleResetPassword(row)} + icon={} + > + Reset Password + + + + {!row.isSystem && ( + + } + onClick={() => setDeleteConfirmUser(row)} + > + Delete User + + + )} + +
+ ), + }, + ]; + + if (loading) { + return ( +
+
+ +
+ +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); + } + + return ( +
+ {error && ( + setError(null)} + /> + )} + {successMsg && ( + setSuccessMsg(null)} + /> + )} + + {/* Table Section */} + + columns={columns} + data={paginatedUsers} + leftHeaderActions={ +
+ handleSearchChange(e.target.value)} + leftIcon={} + className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]" + containerClassName="!gap-0" + /> +
+ } + rightHeaderActions={ + + } + className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]" + onClick={handleCreateUser} + > + Provision User + + + } + currentPage={currentPage} + totalPages={totalPages} + totalItems={totalItems} + startIndex={startIndex} + endIndex={endIndex} + onPageChange={handlePageChange} + itemName="Users" + rowClassName={() => 'bg-white border-b border-gray-100 hover:bg-gray-50/60'} + /> + + {/* Create / Edit User Modal */} + setUserModalOpen(false)} + onSaved={handleSaved} + /> + + {/* Reset Password Modal */} + setResetModalOpen(false)} + onSuccess={handleResetSuccess} + /> + + {/* Confirmation Modal */} + setDeleteConfirmUser(null)} + onConfirm={handleDelete} + title="Delete User Account" + description={`Are you sure you want to permanently remove user "${deleteConfirmUser?.email}"? All active sessions will be invalidated.`} + confirmText={deleting ? 'Deleting...' : 'Confirm Delete'} + variant="danger" + isLoading={deleting} + /> +
+ ); +} diff --git a/src/app/users/index.tsx b/src/app/users/index.tsx new file mode 100644 index 0000000..6c0e63c --- /dev/null +++ b/src/app/users/index.tsx @@ -0,0 +1,9 @@ +import UsersList from './components/UsersList'; + +export default function UsersManagementPage() { + return ( +
+ +
+ ); +} diff --git a/src/components/common/Can.tsx b/src/components/common/Can.tsx new file mode 100644 index 0000000..0cec4c9 --- /dev/null +++ b/src/components/common/Can.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useAuth } from '../../context/AuthContext'; + +interface CanProps { + permission?: string; + permissions?: string[]; + matchAny?: boolean; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export const Can: React.FC = ({ + permission, + permissions, + matchAny = true, + fallback = null, + children, +}) => { + const { hasPermission, hasAnyPermission, hasAllPermissions } = useAuth(); + + let isAllowed = false; + + if (permission) { + isAllowed = hasPermission(permission); + } else if (permissions && permissions.length > 0) { + isAllowed = matchAny + ? hasAnyPermission(permissions) + : hasAllPermissions(permissions); + } else { + isAllowed = true; + } + + if (!isAllowed) { + return <>{fallback}; + } + + return <>{children}; +}; + +export default Can; diff --git a/src/components/common/ProtectedRoute.tsx b/src/components/common/ProtectedRoute.tsx new file mode 100644 index 0000000..0b3d526 --- /dev/null +++ b/src/components/common/ProtectedRoute.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { ShieldAlert, Shield } from 'lucide-react'; + +interface ProtectedRouteProps { + children: React.ReactNode; + permission?: string; + permissions?: string[]; + matchAny?: boolean; +} + +export const ProtectedRoute: React.FC = ({ + children, + permission, + permissions, + matchAny = true, +}) => { + const { isAuthenticated, isLoading, hasPermission, hasAnyPermission, hasAllPermissions } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+
+
+
+ +
+
+

+ Authenticating Aero Resolve Session... +

+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + // Check required permissions + let isAuthorized = true; + if (permission) { + isAuthorized = hasPermission(permission); + } else if (permissions && permissions.length > 0) { + isAuthorized = matchAny ? hasAnyPermission(permissions) : hasAllPermissions(permissions); + } + + if (!isAuthorized) { + return ( +
+
+ +
+

Access Restricted

+

+ Your current role does not have the required security clearance to access this operational module. +

+
+ Required Clearance: {permission || permissions?.join(', ')} +
+
+ ); + } + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..2ee07a4 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,142 @@ +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { ApiClient } from '../app/api/ApiClient'; + +export interface UserRole { + id: string; + name: string; + slug: string; + description?: string; + isSystem: boolean; +} + +export interface AuthUser { + id: string; + email: string; + firstName: string; + lastName?: string; + fullName: string; + roleId: string; + role: UserRole; + tenantId?: string | null; + isActive: boolean; + isSystem: boolean; + permissions: string[]; + lastLoginAt?: string; + createdAt: string; +} + +interface AuthContextType { + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + refreshProfile: () => Promise; + hasPermission: (permissionCode: string) => boolean; + hasAnyPermission: (permissionCodes: string[]) => boolean; + hasAllPermissions: (permissionCodes: string[]) => boolean; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Verify active session on app boot + const refreshProfile = useCallback(async () => { + try { + const profile = await ApiClient.get('/auth/me'); + setUser(profile); + } catch { + setUser(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refreshProfile(); + }, [refreshProfile]); + + const login = async (email: string, password: string): Promise => { + setIsLoading(true); + try { + const response = await ApiClient.post( + '/auth/login', + { email, password } + ); + setUser(response.user); + return response.user; + } finally { + setIsLoading(false); + } + }; + + const logout = async () => { + try { + await ApiClient.post('/auth/logout'); + } catch (e) { + console.warn('Logout API error:', e); + } finally { + setUser(null); + window.location.href = '/login'; + } + }; + + const hasPermission = useCallback( + (permissionCode: string): boolean => { + if (!user) return false; + // Super admin has all permissions + if (user.role?.slug === 'super_admin' || user.isSystem) return true; + return user.permissions?.includes(permissionCode) ?? false; + }, + [user] + ); + + const hasAnyPermission = useCallback( + (permissionCodes: string[]): boolean => { + if (!user) return false; + if (user.role?.slug === 'super_admin' || user.isSystem) return true; + return permissionCodes.some((code) => user.permissions?.includes(code)); + }, + [user] + ); + + const hasAllPermissions = useCallback( + (permissionCodes: string[]): boolean => { + if (!user) return false; + if (user.role?.slug === 'super_admin' || user.isSystem) return true; + return permissionCodes.every((code) => user.permissions?.includes(code)); + }, + [user] + ); + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export default AuthContext; diff --git a/src/layout/AppHeader.tsx b/src/layout/AppHeader.tsx index 2b11f2e..ffd401d 100644 --- a/src/layout/AppHeader.tsx +++ b/src/layout/AppHeader.tsx @@ -10,6 +10,9 @@ const PAGE_META: Record = { '/action-builder': { title: 'Configuration - Action Builder', subtitle: 'Configure dynamic categories, action types, metadata fields, and dynamic user form workflows.' }, '/config': { title: 'Configuration', subtitle: 'Manage dynamic action workflows, categories, metadata fields, and system master data.' }, '/recovery': { title: 'Recovery Incidents', subtitle: 'Operational workspace for managing passenger disruption cases.' }, + '/users': { title: 'User Management', subtitle: 'Provision accounts, assign role permissions, and control tenant access.' }, + '/roles': { title: 'Roles & Permissions', subtitle: 'Configure role-based access control and granular permission matrices.' }, + '/audit-logs': { title: 'Audit Trail & Compliance', subtitle: 'Immutable chronological event ledger for operational compliance.' }, }; interface AppHeaderProps { diff --git a/src/layout/AppSidebar.tsx b/src/layout/AppSidebar.tsx index 225ba05..1d24c5b 100644 --- a/src/layout/AppSidebar.tsx +++ b/src/layout/AppSidebar.tsx @@ -11,17 +11,28 @@ import { GearIcon, ClockCounterClockwiseIcon, CaretDoubleRightIcon, + ShieldCheckIcon, + LockKeyIcon, } from "@phosphor-icons/react"; -import { ShieldCheckIcon } from "lucide-react"; +import { useAuth } from "../context/AuthContext"; -const NAV_ITEMS = [ - { label: "Dashboard", path: "/", icon: SquaresFourIcon }, - { label: "Simulation Engine", path: "/simulation", icon: FadersIcon }, - { label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon }, - { label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon }, - { label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon }, - { label: "Configuration", path: "/config", icon: GearIcon }, - { label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon }, +interface NavItem { + label: string; + path: string; + icon: any; + permission?: string; +} + +const NAV_ITEMS: NavItem[] = [ + { label: "Dashboard", path: "/", icon: SquaresFourIcon, permission: "dashboard:view" }, + { label: "Simulation Engine", path: "/simulation", icon: FadersIcon, permission: "simulation:view" }, + { label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon, permission: "recovery:view" }, + { label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon, permission: "cohorts:view" }, + { label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon, permission: "policy_engine:view" }, + { label: "Configuration", path: "/config", icon: GearIcon, permission: "config:view" }, + { label: "User Management", path: "/users", icon: UsersFourIcon, permission: "users:view" }, + { label: "Roles & Permissions", path: "/roles", icon: LockKeyIcon, permission: "roles:view" }, + { label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon, permission: "audit_logs:view" }, ]; interface AppSidebarProps { @@ -32,6 +43,19 @@ interface AppSidebarProps { export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) { const location = useLocation(); const [isCollapsed, setIsCollapsed] = useState(false); + const { user, logout, hasPermission } = useAuth(); + + const visibleNavItems = NAV_ITEMS.filter((item) => { + if (!item.permission) return true; + return hasPermission(item.permission); + }); + + const initials = user + ? `${user.firstName?.[0] || ''}${user.lastName?.[0] || ''}`.toUpperCase() || user.email?.[0]?.toUpperCase() || 'AD' + : 'AD'; + + const userDisplayName = user?.fullName || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || user?.email || 'Admin Demo'; + const roleDisplayName = user?.role?.name || (user?.isSystem ? 'System Administrator' : 'User'); return ( <> @@ -95,7 +119,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {