feat: initialize core frontend modules including user management, role-based access, and configuration interfaces
This commit is contained in:
+7
-4
@@ -1,12 +1,15 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import AppRoutes from './AppRoutes'
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import AppRoutes from './AppRoutes';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
+139
-22
@@ -1,40 +1,157 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { Route, Routes, Navigate } from "react-router-dom";
|
import { Route, Routes, Navigate } from "react-router-dom";
|
||||||
import Layout from "./layout/AppLayout";
|
import Layout from "./layout/AppLayout";
|
||||||
|
import ProtectedRoute from "./components/common/ProtectedRoute";
|
||||||
|
|
||||||
|
const LoginPage = lazy(() => import("./app/authentication"));
|
||||||
const HomePage = lazy(() => import('./app/dashboard'))
|
const HomePage = lazy(() => import("./app/dashboard"));
|
||||||
const CohortManage = lazy(() => import('./app/cohartManage'))
|
const SimulationPage = lazy(() => import("./app/simulation"));
|
||||||
const PolicyEngineList = lazy(() => import('./app/policyEngine/components/PolicyEngineList'))
|
const CohortManage = lazy(() => import("./app/cohartManage"));
|
||||||
const AddPolicyEngine = lazy(() => import('./app/policyEngine/components/AddPolicyEngine'))
|
const PolicyEngineList = lazy(() => import("./app/policyEngine/components/PolicyEngineList"));
|
||||||
const RecoveryIncidentsList = lazy(() => import('./app/recoveryIncidents/components/RecoveryIncidentsList'))
|
const AddPolicyEngine = lazy(() => import("./app/policyEngine/components/AddPolicyEngine"));
|
||||||
const RecoveryIncidentTabs = lazy(() => import('./app/recoveryIncidents/tabs/index'))
|
const RecoveryIncidentsList = lazy(() => import("./app/recoveryIncidents/components/RecoveryIncidentsList"));
|
||||||
const AuditLogsList = lazy(() => import('./app/auditLogs/components/AuditLogsList'))
|
const RecoveryIncidentTabs = lazy(() => import("./app/recoveryIncidents/tabs/index"));
|
||||||
const ConfigurationPage = lazy(() => import('./app/configuration'))
|
const ConfigurationPage = lazy(() => import("./app/configuration"));
|
||||||
const SimulationPage = lazy(() => import('./app/simulation'))
|
const AuditLogsList = lazy(() => import("./app/auditLogs/components/AuditLogsList"));
|
||||||
|
const UsersPage = lazy(() => import("./app/users"));
|
||||||
|
const RolesPage = lazy(() => import("./app/roles"));
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Suspense
|
||||||
<Suspense fallback={null}>
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#F4F7F6]">
|
||||||
|
<div className="w-10 h-10 border-4 border-slate-200 border-t-teal-600 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
{/* Public Login Route */}
|
||||||
<Route path="/simulation" element={<SimulationPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/cohorts" element={<CohortManage />} />
|
|
||||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
{/* Protected Application Routes */}
|
||||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
<Route
|
||||||
<Route path="/policy-engine/edit/:id" element={<AddPolicyEngine />} />
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-[400px] flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 border-3 border-slate-200 border-t-teal-600 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="dashboard:view">
|
||||||
|
<HomePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/simulation"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="simulation:view">
|
||||||
|
<SimulationPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cohorts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="cohorts:view">
|
||||||
|
<CohortManage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/policy-engine"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="policy_engine:view">
|
||||||
|
<PolicyEngineList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/policy-engine/add"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="policy_engine:create">
|
||||||
|
<AddPolicyEngine />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/policy-engine/edit/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="policy_engine:edit">
|
||||||
|
<AddPolicyEngine />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/action-builder"
|
path="/action-builder"
|
||||||
element={<Navigate to="/config?tab=action-builder" replace />}
|
element={<Navigate to="/config?tab=action-builder" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="/config" element={<ConfigurationPage />} />
|
<Route
|
||||||
<Route path="/recovery" element={<RecoveryIncidentsList />} />
|
path="/config"
|
||||||
<Route path="/recovery/:id" element={<RecoveryIncidentTabs />} />
|
element={
|
||||||
<Route path="/audit-logs" element={<AuditLogsList />} />
|
<ProtectedRoute permission="config:view">
|
||||||
|
<ConfigurationPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/recovery"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="recovery:view">
|
||||||
|
<RecoveryIncidentsList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/recovery/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="recovery:view">
|
||||||
|
<RecoveryIncidentTabs />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/users"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="users:view">
|
||||||
|
<UsersPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/roles"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="roles:view">
|
||||||
|
<RolesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/audit-logs"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute permission="audit_logs:view">
|
||||||
|
<AuditLogsList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-15
@@ -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({
|
export const ApiClient = axios.create({
|
||||||
baseURL: API_BASE,
|
baseURL: API_BASE,
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Tenant-Id': 'demo-airline',
|
'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
|
// Request Interceptor
|
||||||
ApiClient.interceptors.request.use(
|
ApiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
// You can attach authorization tokens here if needed
|
// Cookies are automatically sent via withCredentials: true
|
||||||
// const token = localStorage.getItem('token');
|
|
||||||
// if (token) {
|
|
||||||
// config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
// }
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -26,17 +40,53 @@ ApiClient.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Response Interceptor
|
// Response Interceptor with automatic silent refresh
|
||||||
ApiClient.interceptors.response.use(
|
ApiClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// Return just the data payload by default for convenience
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error: AxiosError) => {
|
||||||
// Handle global API errors here (e.g., redirect on 401 Unauthorized)
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
if (error.response?.status === 401) {
|
|
||||||
console.error('Unauthorized access - perhaps redirect to login?');
|
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);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export default ApiClient;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { AuditLog, AuditLogFilters } from "../AuditLogsTypes";
|
|||||||
import { AUDIT_MODULE_LABELS, AUDIT_ACTION_VARIANTS } from "../AuditLogsTypes";
|
import { AUDIT_MODULE_LABELS, AUDIT_ACTION_VARIANTS } from "../AuditLogsTypes";
|
||||||
import { getAuditLogs } from "../AuditLogsApi";
|
import { getAuditLogs } from "../AuditLogsApi";
|
||||||
import AuditLogDetail from "./AuditLogDetail";
|
import AuditLogDetail from "./AuditLogDetail";
|
||||||
|
import Can from "../../../components/common/Can";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -335,6 +336,7 @@ export default function AuditLogsList() {
|
|||||||
className="!border-[#1E8E3E] !bg-[#E6F4EA] !h-[40px] hover:!bg-[#E6F4EA]"
|
className="!border-[#1E8E3E] !bg-[#E6F4EA] !h-[40px] hover:!bg-[#E6F4EA]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Can permission="audit_logs:export">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={handleExportCSV}
|
onClick={handleExportCSV}
|
||||||
@@ -343,6 +345,7 @@ export default function AuditLogsList() {
|
|||||||
<Export size={16} weight="bold" />
|
<Export size={16} weight="bold" />
|
||||||
Export CSV
|
Export CSV
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|||||||
@@ -1,10 +1,254 @@
|
|||||||
function AboutPage() {
|
import React, { useState } from 'react';
|
||||||
return (
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
<section className="page about-page">
|
import { useAuth } from '../../context/AuthContext';
|
||||||
<h1>About Page</h1>
|
import {
|
||||||
<p>This is the about page. Add your about content here.</p>
|
Lock,
|
||||||
</section>
|
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<string | null>(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 (
|
||||||
|
<div className="min-h-screen w-full flex flex-col md:flex-row bg-[#0E1525] text-slate-100 font-sans selection:bg-teal-500 selection:text-white">
|
||||||
|
{/* Left / Hero Column */}
|
||||||
|
<div className="relative hidden md:flex md:w-1/2 lg:w-3/5 flex-col justify-between p-12 overflow-hidden bg-gradient-to-br from-[#0F172A] via-[#111C33] to-[#0A101D]">
|
||||||
|
{/* Background glow & radar circles */}
|
||||||
|
<div className="absolute top-1/4 -left-20 w-96 h-96 bg-teal-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-10 right-10 w-96 h-96 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(#1e293b_1px,transparent_1px)] [background-size:24px_24px] opacity-25 pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Top Logo */}
|
||||||
|
<div className="relative z-10 flex items-center gap-3">
|
||||||
|
<div className="w-11 h-11 bg-gradient-to-br from-teal-400 to-emerald-600 rounded-2xl flex items-center justify-center shadow-lg shadow-teal-500/20 text-white">
|
||||||
|
<Plane size={24} className="rotate-45" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-black tracking-tight text-white flex items-center gap-2">
|
||||||
|
AERO RESOLVE
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded-full bg-teal-500/20 text-teal-300 border border-teal-500/30">
|
||||||
|
Enterprise
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400 font-medium">
|
||||||
|
Aviation Passenger Recovery & Compensation Intelligence
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Central Content */}
|
||||||
|
<div className="relative z-10 my-auto max-w-lg space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 backdrop-blur-md">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-teal-400" />
|
||||||
|
<span className="text-xs font-semibold text-slate-300 tracking-wide">
|
||||||
|
Secure Role-Based Access Control (RBAC)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl lg:text-4xl font-extrabold text-white leading-tight tracking-tight">
|
||||||
|
Next-Generation Flight Disruption Orchestration
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm lg:text-base text-slate-400 leading-relaxed">
|
||||||
|
Automate passenger segmentation, multi-jurisdiction regulatory compensation, goodwill
|
||||||
|
policies, and live manifest recovery simulations in real time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-800/80">
|
||||||
|
<div className="p-4 rounded-xl bg-white/[0.03] border border-white/5">
|
||||||
|
<span className="text-2xl font-bold text-white block">99.98%</span>
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Uptime Reliability</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-xl bg-white/[0.03] border border-white/5">
|
||||||
|
<span className="text-2xl font-bold text-teal-400 block">< 150ms</span>
|
||||||
|
<span className="text-xs text-slate-400 font-medium">Policy Engine Evaluation</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Trust Footer */}
|
||||||
|
<div className="relative z-10 flex items-center justify-between text-xs text-slate-500 pt-6 border-t border-slate-800/60">
|
||||||
|
<span>Aero Resolve Operating System v1.0</span>
|
||||||
|
<span>Aerospace High-Security Protocol</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right / Login Form Column */}
|
||||||
|
<div className="w-full md:w-1/2 lg:w-2/5 flex flex-col justify-center items-center p-6 sm:p-10 lg:p-12 bg-[#0B111E]">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
{/* Header Mobile / Title */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex md:hidden items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-teal-500 rounded-xl flex items-center justify-center text-white shadow-md">
|
||||||
|
<Plane size={22} className="rotate-45" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-white tracking-tight">Aero Resolve</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl lg:text-3xl font-extrabold text-white tracking-tight">
|
||||||
|
Sign In to Terminal
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Enter your credentials to access operations management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Demo Fill Helper */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-teal-950/40 border border-teal-500/20 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Sparkles className="w-4 h-4 text-teal-400 shrink-0" />
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="font-semibold text-teal-200 block">Super Admin Demo Credentials</span>
|
||||||
|
<span className="text-slate-400 text-[11px]">admin@aeroresolve.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleQuickFill}
|
||||||
|
className="px-3 py-1.5 text-xs font-bold text-teal-300 bg-teal-500/10 hover:bg-teal-500/25 border border-teal-500/30 rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Fill Credentials
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30 flex items-start gap-3 text-red-400 text-xs animate-shake">
|
||||||
|
<AlertCircle size={18} className="shrink-0 mt-0.5" />
|
||||||
|
<span className="leading-relaxed">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-300">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<Mail size={18} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-slate-300">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-teal-400 hover:text-teal-300 transition-colors cursor-pointer">
|
||||||
|
Forgot Password?
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<Lock size={18} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3.5 px-4 bg-gradient-to-r from-teal-500 to-emerald-600 hover:from-teal-400 hover:to-emerald-500 text-slate-950 font-bold rounded-xl shadow-lg shadow-teal-500/20 hover:shadow-teal-500/30 active:scale-[0.99] transition-all duration-200 flex items-center justify-center gap-2 cursor-pointer disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-slate-950 border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Sign In Securely</span>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Security badge footer */}
|
||||||
|
<div className="pt-4 flex items-center justify-center gap-2 text-[11px] text-slate-500 font-medium">
|
||||||
|
<KeyRound size={13} className="text-teal-400" />
|
||||||
|
<span>Encrypted with HTTP-Only Cookie Authentication</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
updateCohartStatus,
|
updateCohartStatus,
|
||||||
} from "../CohartManageApi";
|
} from "../CohartManageApi";
|
||||||
import type { CohartResponse } from "../CohartManageTypes";
|
import type { CohartResponse } from "../CohartManageTypes";
|
||||||
|
import Can from "../../../components/common/Can";
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -191,6 +192,7 @@ export default function CohortList() {
|
|||||||
accessor: (row) => (
|
accessor: (row) => (
|
||||||
<CustomActionMenu>
|
<CustomActionMenu>
|
||||||
{row.status !== "Active" && (
|
{row.status !== "Active" && (
|
||||||
|
<Can permission="cohorts:edit">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<CheckIcon size={15} />}
|
icon={<CheckIcon size={15} />}
|
||||||
variant="success"
|
variant="success"
|
||||||
@@ -198,21 +200,27 @@ export default function CohortList() {
|
|||||||
>
|
>
|
||||||
Activate
|
Activate
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
)}
|
)}
|
||||||
{row.status === "Active" && (
|
{row.status === "Active" && (
|
||||||
|
<Can permission="cohorts:edit">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<XIcon size={15} />}
|
icon={<XIcon size={15} />}
|
||||||
onClick={() => setDeactivateTarget(row)}
|
onClick={() => setDeactivateTarget(row)}
|
||||||
>
|
>
|
||||||
Deactivate
|
Deactivate
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
)}
|
)}
|
||||||
|
<Can permission="cohorts:edit">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<PencilSimpleIcon size={15} />}
|
icon={<PencilSimpleIcon size={15} />}
|
||||||
onClick={() => setEditTarget(row)}
|
onClick={() => setEditTarget(row)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
<Can permission="cohorts:delete">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<TrashIcon size={15} />}
|
icon={<TrashIcon size={15} />}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -220,6 +228,7 @@ export default function CohortList() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
</CustomActionMenu>
|
</CustomActionMenu>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -291,6 +300,7 @@ export default function CohortList() {
|
|||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Can permission="cohorts:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -300,6 +310,7 @@ export default function CohortList() {
|
|||||||
>
|
>
|
||||||
Create Cohort
|
Create Cohort
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
|
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
|
||||||
import { CustomInput, CustomButton } from '../../../../components/custom';
|
import { CustomInput, CustomButton } from '../../../../components/custom';
|
||||||
import type { ActionType } from '../ActionBuilderTypes';
|
import type { ActionType } from '../ActionBuilderTypes';
|
||||||
|
import Can from '../../../../components/common/Can';
|
||||||
|
|
||||||
interface ActionTypesColumnProps {
|
interface ActionTypesColumnProps {
|
||||||
actionTypes: ActionType[];
|
actionTypes: ActionType[];
|
||||||
@@ -40,6 +41,7 @@ export function ActionTypesColumn({
|
|||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-[16px] font-bold text-[#0F172B]">Action Types</h3>
|
<h3 className="text-[16px] font-bold text-[#0F172B]">Action Types</h3>
|
||||||
|
<Can permission="config:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -49,6 +51,7 @@ export function ActionTypesColumn({
|
|||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +117,7 @@ export function ActionTypesColumn({
|
|||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{/* Hover Actions */}
|
{/* Hover Actions */}
|
||||||
<div className="hidden group-hover:flex items-center gap-1">
|
<div className="hidden group-hover:flex items-center gap-1">
|
||||||
|
<Can permission="config:edit">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => onOpenEdit(t, e)}
|
onClick={(e) => onOpenEdit(t, e)}
|
||||||
@@ -123,6 +127,8 @@ export function ActionTypesColumn({
|
|||||||
>
|
>
|
||||||
<PencilSimpleIcon size={15} weight="bold" />
|
<PencilSimpleIcon size={15} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
|
<Can permission="config:delete">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => onDelete(t, e)}
|
onClick={(e) => onDelete(t, e)}
|
||||||
@@ -132,6 +138,7 @@ export function ActionTypesColumn({
|
|||||||
>
|
>
|
||||||
<TrashIcon size={15} weight="bold" />
|
<TrashIcon size={15} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Count Badge */}
|
{/* Count Badge */}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
|
import { PlusIcon, MagnifyingGlassIcon, PencilSimpleIcon, TrashIcon } from '@phosphor-icons/react';
|
||||||
import { CustomInput, CustomButton } from '../../../../components/custom';
|
import { CustomInput, CustomButton } from '../../../../components/custom';
|
||||||
import type { ActionCategory, ActionType } from '../ActionBuilderTypes';
|
import type { ActionCategory, ActionType } from '../ActionBuilderTypes';
|
||||||
|
import Can from '../../../../components/common/Can';
|
||||||
|
|
||||||
interface CategoriesColumnProps {
|
interface CategoriesColumnProps {
|
||||||
categories: ActionCategory[];
|
categories: ActionCategory[];
|
||||||
@@ -36,6 +37,7 @@ export function CategoriesColumn({
|
|||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-[16px] font-bold text-[#0F172B]">Categories</h3>
|
<h3 className="text-[16px] font-bold text-[#0F172B]">Categories</h3>
|
||||||
|
<Can permission="config:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -44,6 +46,7 @@ export function CategoriesColumn({
|
|||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +107,7 @@ export function CategoriesColumn({
|
|||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{/* Hover Actions */}
|
{/* Hover Actions */}
|
||||||
<div className="hidden group-hover:flex items-center gap-1">
|
<div className="hidden group-hover:flex items-center gap-1">
|
||||||
|
<Can permission="config:edit">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => onOpenEdit(cat, e)}
|
onClick={(e) => onOpenEdit(cat, e)}
|
||||||
@@ -113,6 +117,8 @@ export function CategoriesColumn({
|
|||||||
>
|
>
|
||||||
<PencilSimpleIcon size={15} weight="bold" />
|
<PencilSimpleIcon size={15} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
|
<Can permission="config:delete">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => onDelete(cat, e)}
|
onClick={(e) => onDelete(cat, e)}
|
||||||
@@ -122,6 +128,7 @@ export function CategoriesColumn({
|
|||||||
>
|
>
|
||||||
<TrashIcon size={15} weight="bold" />
|
<TrashIcon size={15} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Count Badge */}
|
{/* Count Badge */}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
DotsSixVerticalIcon,
|
DotsSixVerticalIcon,
|
||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import { CustomInput, CustomButton, Skeleton } from '../../../../components/custom';
|
|
||||||
import type { FieldDefinition } from '../ActionBuilderTypes';
|
import type { FieldDefinition } from '../ActionBuilderTypes';
|
||||||
|
import Can from '../../../../components/common/Can';
|
||||||
|
import { CustomButton, CustomInput, Skeleton } from '../../../../components/custom';
|
||||||
|
|
||||||
interface ConfigurationFieldsColumnProps {
|
interface ConfigurationFieldsColumnProps {
|
||||||
fields: FieldDefinition[];
|
fields: FieldDefinition[];
|
||||||
@@ -47,6 +48,7 @@ export function ConfigurationFieldsColumn({
|
|||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-[16px] font-bold text-[#0F172B]">Configuration Field</h3>
|
<h3 className="text-[16px] font-bold text-[#0F172B]">Configuration Field</h3>
|
||||||
|
<Can permission="config:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -56,6 +58,7 @@ export function ConfigurationFieldsColumn({
|
|||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
@@ -184,6 +187,7 @@ export function ConfigurationFieldsColumn({
|
|||||||
<span className="text-slate-300 font-light mx-0.5">|</span>
|
<span className="text-slate-300 font-light mx-0.5">|</span>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
|
<Can permission="config:edit">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenEdit(f)}
|
onClick={() => onOpenEdit(f)}
|
||||||
@@ -192,6 +196,9 @@ export function ConfigurationFieldsColumn({
|
|||||||
>
|
>
|
||||||
<PencilSimpleLineIcon size={18} weight="bold" />
|
<PencilSimpleLineIcon size={18} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
<Can permission="config:delete">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDelete(f)}
|
onClick={() => onDelete(f)}
|
||||||
@@ -200,6 +207,7 @@ export function ConfigurationFieldsColumn({
|
|||||||
>
|
>
|
||||||
<TrashIcon size={18} weight="bold" />
|
<TrashIcon size={18} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CustomStatus,
|
CustomStatus,
|
||||||
} from '../../../../components/custom';
|
} from '../../../../components/custom';
|
||||||
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
|
import type { MasterDataCategoryItem, MasterDataItem } from '../MasterDataTypes';
|
||||||
|
import Can from '../../../../components/common/Can';
|
||||||
|
|
||||||
interface MasterItemTableProps {
|
interface MasterItemTableProps {
|
||||||
selectedCategory: MasterDataCategoryItem | null;
|
selectedCategory: MasterDataCategoryItem | null;
|
||||||
@@ -63,6 +64,7 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Can permission="config:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={onOpenCreate}
|
onClick={onOpenCreate}
|
||||||
@@ -72,6 +74,7 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
|||||||
>
|
>
|
||||||
Add Master Value
|
Add Master Value
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
@@ -155,21 +158,25 @@ export const MasterItemTable: React.FC<MasterItemTableProps> = ({
|
|||||||
|
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Can permission="config:edit">
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpenEdit(item)}
|
onClick={() => onOpenEdit(item)}
|
||||||
title="Edit Item"
|
title="Edit Item"
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors"
|
className="p-1.5 rounded-lg text-slate-500 hover:text-[#1E7D5C] hover:bg-[#E8F3EF] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<PencilSimpleIcon size={16} weight="bold" />
|
<PencilSimpleIcon size={16} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
<Can permission="config:delete">
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpenDelete(item)}
|
onClick={() => onOpenDelete(item)}
|
||||||
title="Delete Item"
|
title="Delete Item"
|
||||||
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<TrashIcon size={16} weight="bold" />
|
<TrashIcon size={16} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export default function HomePage() {
|
|||||||
async function loadDashboardData() {
|
async function loadDashboardData() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [metricsRes, exposureRes, mixRes, incidentsRes] = await Promise.all([
|
const [metricsRes, exposureRes, mixRes, incidentsRes] =
|
||||||
|
await Promise.all([
|
||||||
getDashboardMetrics(),
|
getDashboardMetrics(),
|
||||||
getExposureData(),
|
getExposureData(),
|
||||||
getDisruptionMixData(),
|
getDisruptionMixData(),
|
||||||
@@ -57,7 +58,10 @@ export default function HomePage() {
|
|||||||
{/* Top Stat Cards Skeleton */}
|
{/* Top Stat Cards Skeleton */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="bg-white rounded-[14px] py-4 px-5 border border-gray-100/80 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-[102px]">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-white rounded-[14px] py-4 px-5 border border-gray-100/80 shadow-[0_2px_4px_rgba(0,0,0,0.02)] flex flex-col justify-between h-[102px]"
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton width="60%" height={16} />
|
<Skeleton width="60%" height={16} />
|
||||||
<Skeleton width="40%" height={28} />
|
<Skeleton width="40%" height={28} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
} from '../../../components/custom';
|
} from '../../../components/custom';
|
||||||
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
|
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
|
||||||
|
import Can from '../../../components/common/Can';
|
||||||
import {
|
import {
|
||||||
getJurisdictionOptions,
|
getJurisdictionOptions,
|
||||||
getCohortOptions,
|
getCohortOptions,
|
||||||
@@ -1648,6 +1649,7 @@ export default function AddPolicyEngine() {
|
|||||||
<CustomStatus status={status} />
|
<CustomStatus status={status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<Can permissions={['policy_engine:create', 'policy_engine:edit']}>
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="!text-[#1E7D5C] !bg-[#E8F3EF] hover:!bg-[#d9ece4] !border-none font-semibold px-6 disabled:opacity-50"
|
className="!text-[#1E7D5C] !bg-[#E8F3EF] hover:!bg-[#d9ece4] !border-none font-semibold px-6 disabled:opacity-50"
|
||||||
@@ -1656,6 +1658,7 @@ export default function AddPolicyEngine() {
|
|||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
|
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
|
||||||
@@ -1664,6 +1667,7 @@ export default function AddPolicyEngine() {
|
|||||||
>
|
>
|
||||||
Cancel Policy
|
Cancel Policy
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
<Can permission="policy_engine:publish">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
|
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
|
||||||
@@ -1672,6 +1676,7 @@ export default function AddPolicyEngine() {
|
|||||||
>
|
>
|
||||||
{isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'}
|
{isSaving ? 'Deploying...' : isEditMode ? 'Update & Deploy Policy' : 'Deploy Policy'}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "../../../components/custom";
|
} from "../../../components/custom";
|
||||||
import type { Column } from "../../../components/custom/CustomTable";
|
import type { Column } from "../../../components/custom/CustomTable";
|
||||||
import type { PolicyEngineResponse } from "../PolicyEngineTypes";
|
import type { PolicyEngineResponse } from "../PolicyEngineTypes";
|
||||||
|
import Can from "../../../components/common/Can";
|
||||||
import {
|
import {
|
||||||
getPolicies,
|
getPolicies,
|
||||||
deletePolicy,
|
deletePolicy,
|
||||||
@@ -201,6 +202,7 @@ export default function PolicyEngineList() {
|
|||||||
accessor: (row) => (
|
accessor: (row) => (
|
||||||
<CustomActionMenu>
|
<CustomActionMenu>
|
||||||
{row.status?.toLowerCase() === "inactive" && (
|
{row.status?.toLowerCase() === "inactive" && (
|
||||||
|
<Can permission="policy_engine:publish">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<ChecksIcon size={15} />}
|
icon={<ChecksIcon size={15} />}
|
||||||
variant="success"
|
variant="success"
|
||||||
@@ -208,21 +210,27 @@ export default function PolicyEngineList() {
|
|||||||
>
|
>
|
||||||
Activate
|
Activate
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
)}
|
)}
|
||||||
{row.status?.toLowerCase() === "active" && (
|
{row.status?.toLowerCase() === "active" && (
|
||||||
|
<Can permission="policy_engine:publish">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<XIcon size={15} />}
|
icon={<XIcon size={15} />}
|
||||||
onClick={() => setDeactivateTarget(row)}
|
onClick={() => setDeactivateTarget(row)}
|
||||||
>
|
>
|
||||||
Deactivate
|
Deactivate
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
)}
|
)}
|
||||||
|
<Can permission="policy_engine:edit">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<PencilSimpleIcon size={15} />}
|
icon={<PencilSimpleIcon size={15} />}
|
||||||
onClick={() => navigate(`/policy-engine/add?id=${row.id}`)}
|
onClick={() => navigate(`/policy-engine/add?id=${row.id}`)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
<Can permission="policy_engine:delete">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<TrashIcon size={15} />}
|
icon={<TrashIcon size={15} />}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -230,6 +238,7 @@ export default function PolicyEngineList() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
</CustomActionMenu>
|
</CustomActionMenu>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -290,6 +299,7 @@ export default function PolicyEngineList() {
|
|||||||
}
|
}
|
||||||
rightHeaderActions={
|
rightHeaderActions={
|
||||||
<>
|
<>
|
||||||
|
<Can permission="policy_engine:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -299,6 +309,7 @@ export default function PolicyEngineList() {
|
|||||||
>
|
>
|
||||||
Deploy New Policy
|
Deploy New Policy
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
CustomAlertBanner,
|
CustomAlertBanner,
|
||||||
CustomSuccessModal,
|
CustomSuccessModal,
|
||||||
} from "../../../components/custom";
|
} from "../../../components/custom";
|
||||||
|
import Can from "../../../components/common/Can";
|
||||||
import type { Column } from "../../../components/custom/CustomTable";
|
import type { Column } from "../../../components/custom/CustomTable";
|
||||||
import type {
|
import type {
|
||||||
RecoveryIncident,
|
RecoveryIncident,
|
||||||
@@ -479,6 +480,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
>
|
>
|
||||||
View Details
|
View Details
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
<Can permission="recovery:edit">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingIncident(row);
|
setEditingIncident(row);
|
||||||
@@ -490,6 +492,8 @@ export default function RecoveryIncidentsList() {
|
|||||||
>
|
>
|
||||||
Edit Incident
|
Edit Incident
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
<Can permission="recovery:override">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={
|
icon={
|
||||||
<CheckCircleIcon size={16} className="text-green-500" />
|
<CheckCircleIcon size={16} className="text-green-500" />
|
||||||
@@ -498,6 +502,8 @@ export default function RecoveryIncidentsList() {
|
|||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
<Can permission="recovery:override">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon={<XCircleIcon size={16} className="text-red-500" />}
|
icon={<XCircleIcon size={16} className="text-red-500" />}
|
||||||
@@ -505,12 +511,15 @@ export default function RecoveryIncidentsList() {
|
|||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
<Can permission="recovery:edit">
|
||||||
<CustomActionItem
|
<CustomActionItem
|
||||||
icon={<ClockIcon size={16} className="text-yellow-600" />}
|
icon={<ClockIcon size={16} className="text-yellow-600" />}
|
||||||
onClick={() => handleStatusChange(row, "Under Review")}
|
onClick={() => handleStatusChange(row, "Under Review")}
|
||||||
>
|
>
|
||||||
Mark for Review
|
Mark for Review
|
||||||
</CustomActionItem>
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
</CustomActionMenu>
|
</CustomActionMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -618,6 +627,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
>
|
>
|
||||||
{isGrouped ? "Ungroup" : "Group by Flight"}
|
{isGrouped ? "Ungroup" : "Group by Flight"}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
<Can permission="recovery:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -630,6 +640,7 @@ export default function RecoveryIncidentsList() {
|
|||||||
>
|
>
|
||||||
New Incident
|
New Incident
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
|
|||||||
@@ -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<string, PermissionItem[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRolePayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRolePayload {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RolesApi = {
|
||||||
|
getRoles: async (): Promise<RoleItem[]> => {
|
||||||
|
return ApiClient.get<any, RoleItem[]>('/roles');
|
||||||
|
},
|
||||||
|
|
||||||
|
getRoleById: async (id: string): Promise<RoleItem> => {
|
||||||
|
return ApiClient.get<any, RoleItem>(`/roles/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPermissions: async (): Promise<PermissionsResponse> => {
|
||||||
|
return ApiClient.get<any, PermissionsResponse>('/permissions');
|
||||||
|
},
|
||||||
|
|
||||||
|
createRole: async (payload: CreateRolePayload): Promise<RoleItem> => {
|
||||||
|
return ApiClient.post<any, RoleItem>('/roles', payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRole: async (id: string, payload: UpdateRolePayload): Promise<RoleItem> => {
|
||||||
|
return ApiClient.patch<any, RoleItem>(`/roles/${id}`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRole: async (id: string): Promise<{ success: boolean; message: string }> => {
|
||||||
|
return ApiClient.delete<any, { success: boolean; message: string }>(`/roles/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RolesApi;
|
||||||
@@ -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<Set<string>>(new Set());
|
||||||
|
const [groupedPermissions, setGroupedPermissions] = useState<Record<string, PermissionItem[]>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [fetching, setFetching] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 p-6 space-y-4">
|
||||||
|
<Skeleton width={260} height={32} />
|
||||||
|
<Skeleton height={52} />
|
||||||
|
<Skeleton height={200} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
|
{/* Header bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-5 rounded-[14px] border border-gray-100 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<ArrowLeftIcon size={16} />}
|
||||||
|
onClick={onBack}
|
||||||
|
className="!rounded-[10px] !h-[38px]"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</CustomButton>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[#0F172B] tracking-tight flex items-center gap-2">
|
||||||
|
{roleToEdit ? `Edit Role: ${roleToEdit.name}` : 'Create Custom Role'}
|
||||||
|
{isSystemRole && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold px-2.5 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200">
|
||||||
|
<LockKeyIcon size={11} weight="bold" /> System Protected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[#6C766D]">
|
||||||
|
Configure role metadata and assign module-specific action permissions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSystemRole && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="sm"
|
||||||
|
onClick={selectAll}
|
||||||
|
className="!rounded-[8px] !h-[34px]"
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="sm"
|
||||||
|
onClick={deselectAll}
|
||||||
|
className="!rounded-[8px] !h-[34px]"
|
||||||
|
>
|
||||||
|
Deselect All
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<CustomAlertBanner
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Role General Info */}
|
||||||
|
<div className="bg-white p-6 rounded-[16px] border border-gray-100 shadow-sm space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-[#0F172B] uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<SquaresFourIcon size={18} className="text-[#1B9869]" weight="bold" />
|
||||||
|
General Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<CustomInput
|
||||||
|
label="Role Name"
|
||||||
|
required
|
||||||
|
disabled={isSystemRole}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Flight Disruption Lead"
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Description"
|
||||||
|
disabled={isSystemRole}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="e.g. Manages passenger goodwill offers and compensation evaluations"
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission Matrix */}
|
||||||
|
<div className="bg-white p-6 rounded-[16px] border border-gray-100 shadow-sm space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-[#0F172B] uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<ShieldCheckIcon size={18} className="text-[#1B9869]" weight="bold" />
|
||||||
|
Granular Permissions Matrix
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#6C766D] mt-0.5">
|
||||||
|
Selected {selectedPermissionIds.size} permission(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(groupedPermissions).map(([groupName, perms]) => {
|
||||||
|
const groupIds = perms.map((p) => p.id);
|
||||||
|
const allGroupSelected = groupIds.every((id) => selectedPermissionIds.has(id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={groupName}
|
||||||
|
className="rounded-[12px] border border-gray-100 overflow-hidden transition-all bg-[#F3F6F5]/40 hover:bg-white"
|
||||||
|
>
|
||||||
|
{/* Module header */}
|
||||||
|
<div className="px-5 py-3.5 bg-[#F3F6F5] border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CustomCheckBox
|
||||||
|
disabled={isSystemRole}
|
||||||
|
checked={allGroupSelected}
|
||||||
|
onChange={() => toggleGroup(groupName, perms)}
|
||||||
|
/>
|
||||||
|
<span className="text-[13px] font-bold text-[#0F172B]">{groupName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-semibold text-[#6C766D] bg-white px-2.5 py-0.5 rounded-full border border-gray-200">
|
||||||
|
{perms.filter((p) => selectedPermissionIds.has(p.id)).length} / {perms.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission cards grid */}
|
||||||
|
<div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{perms.map((perm) => {
|
||||||
|
const isSelected = selectedPermissionIds.has(perm.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={perm.id}
|
||||||
|
onClick={() => 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' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<CustomCheckBox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={isSystemRole}
|
||||||
|
onChange={() => togglePermission(perm.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 overflow-hidden">
|
||||||
|
<span className="text-xs font-bold text-[#0F172B] block truncate">
|
||||||
|
{perm.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-mono text-[#6C766D] block truncate">
|
||||||
|
{perm.code}
|
||||||
|
</span>
|
||||||
|
{perm.description && (
|
||||||
|
<p className="text-[11px] text-[#6C766D] line-clamp-2 mt-1 leading-tight">
|
||||||
|
{perm.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer save buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="md"
|
||||||
|
onClick={onBack}
|
||||||
|
className="!rounded-[10px] !h-[40px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</CustomButton>
|
||||||
|
{!isSystemRole && (
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
leftIcon={<FloppyDiskIcon size={16} />}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||||
|
>
|
||||||
|
{roleToEdit ? 'Save Changes' : 'Create Role'}
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RoleItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [deleteConfirmRole, setDeleteConfirmRole] = useState<RoleItem | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(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 (
|
||||||
|
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||||
|
<Skeleton width={320} height={36} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton width={148} height={36} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height={180} className="!rounded-[16px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-6">
|
||||||
|
{error && (
|
||||||
|
<CustomAlertBanner
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<CustomAlertBanner
|
||||||
|
message={successMsg}
|
||||||
|
type="success"
|
||||||
|
onClose={() => setSuccessMsg(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Action Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-4 rounded-[14px] border border-gray-100 shadow-sm">
|
||||||
|
<div className="w-full sm:w-[320px]">
|
||||||
|
<CustomInput
|
||||||
|
placeholder="Search roles by name..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||||
|
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
|
||||||
|
containerClassName="!gap-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Can permission="roles:create">
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
leftIcon={<PlusIcon size={16} />}
|
||||||
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||||
|
onClick={onAddRole}
|
||||||
|
>
|
||||||
|
Create Role
|
||||||
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{filteredRoles.map((role) => (
|
||||||
|
<div
|
||||||
|
key={role.id}
|
||||||
|
className="bg-white rounded-[16px] border border-gray-100 p-5 shadow-sm hover:shadow-md transition-all flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-[12px] flex items-center justify-center font-bold text-sm ${
|
||||||
|
role.isSystem
|
||||||
|
? 'bg-amber-100 text-amber-800'
|
||||||
|
: 'bg-emerald-100 text-[#1B9869]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheckIcon size={22} weight="bold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-[#0F172B] text-base leading-tight">
|
||||||
|
{role.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] font-mono text-[#6C766D] block mt-0.5">
|
||||||
|
{role.slug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{role.isSystem ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 border border-amber-200 shrink-0">
|
||||||
|
<LockKeyIcon size={11} weight="bold" /> System Default
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-semibold px-2.5 py-1 rounded-full bg-gray-100 text-gray-600 shrink-0">
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-xs text-[#6C766D] leading-relaxed line-clamp-2 min-h-[32px]">
|
||||||
|
{role.description || 'No description configured for this operational role.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-[10px] bg-[#F3F6F5]">
|
||||||
|
<KeyIcon size={16} className="text-[#1B9869]" weight="bold" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-semibold text-[#6C766D] block uppercase">
|
||||||
|
Permissions
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-[#0F172B]">
|
||||||
|
{role.permissionCount} rules
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-[10px] bg-[#F3F6F5]">
|
||||||
|
<UsersIcon size={16} className="text-blue-600" weight="bold" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-semibold text-[#6C766D] block uppercase">
|
||||||
|
Users
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-[#0F172B]">
|
||||||
|
{role.userCount} assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-4 mt-3 border-t border-gray-100">
|
||||||
|
<Can permission="roles:view">
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<PencilSimpleIcon size={14} />}
|
||||||
|
onClick={() => onEditRole(role)}
|
||||||
|
className="!rounded-[8px] !h-[34px]"
|
||||||
|
>
|
||||||
|
{role.isSystem ? 'View Permissions' : 'Edit Role'}
|
||||||
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
{!role.isSystem && (
|
||||||
|
<Can permission="roles:delete">
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<TrashIcon size={14} className="text-red-500" />}
|
||||||
|
onClick={() => setDeleteConfirmRole(role)}
|
||||||
|
className="!rounded-[8px] !h-[34px] !border-red-200 !text-red-600 hover:!bg-red-50"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={!!deleteConfirmRole}
|
||||||
|
onClose={() => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RoleItem | null>(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 (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
{view === 'list' && (
|
||||||
|
<RolesList onAddRole={handleAddRole} onEditRole={handleEditRole} />
|
||||||
|
)}
|
||||||
|
{(view === 'add' || view === 'edit') && (
|
||||||
|
<AddRoles
|
||||||
|
roleToEdit={selectedRole}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import type { SimulatedPassenger, SimulationFormState } from '../SimulationTypes
|
|||||||
import { createRecoveryIncident } from '../../recoveryIncidents/RecoveryIncidentsApi';
|
import { createRecoveryIncident } from '../../recoveryIncidents/RecoveryIncidentsApi';
|
||||||
import { getCategoryValues } from '../../configuration/masterData/MasterDataApi';
|
import { getCategoryValues } from '../../configuration/masterData/MasterDataApi';
|
||||||
import { evaluateBatchSimulation } from '../SimulationApi';
|
import { evaluateBatchSimulation } from '../SimulationApi';
|
||||||
|
import Can from '../../../components/common/Can';
|
||||||
import {
|
import {
|
||||||
CATEGORY_SIMULATION_CONFIGS,
|
CATEGORY_SIMULATION_CONFIGS,
|
||||||
type CategorySimulationConfig,
|
type CategorySimulationConfig,
|
||||||
@@ -507,6 +508,11 @@ export default function SimulationTerminal() {
|
|||||||
|
|
||||||
{/* Action Button using CustomButton */}
|
{/* Action Button using CustomButton */}
|
||||||
<div className="w-full pt-1">
|
<div className="w-full pt-1">
|
||||||
|
<Can permission="simulation:execute" fallback={
|
||||||
|
<div className="text-center p-2 text-xs text-slate-400 italic">
|
||||||
|
Execution permission (simulation:execute) required to run simulations.
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
loading={isSimulating}
|
loading={isSimulating}
|
||||||
@@ -515,6 +521,7 @@ export default function SimulationTerminal() {
|
|||||||
>
|
>
|
||||||
Run Manifest Simulation
|
Run Manifest Simulation
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -562,6 +569,7 @@ export default function SimulationTerminal() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Can permission="recovery:create">
|
||||||
<CustomButton
|
<CustomButton
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -573,6 +581,7 @@ export default function SimulationTerminal() {
|
|||||||
>
|
>
|
||||||
Save as Recovery
|
Save as Recovery
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Manifest Table */}
|
{/* Dynamic Manifest Table */}
|
||||||
|
|||||||
@@ -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<UserItem[]> => {
|
||||||
|
return ApiClient.get<any, UserItem[]>('/users', {
|
||||||
|
params: { search, roleId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getUserById: async (id: string): Promise<UserItem> => {
|
||||||
|
return ApiClient.get<any, UserItem>(`/users/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser: async (payload: CreateUserPayload): Promise<UserItem> => {
|
||||||
|
return ApiClient.post<any, UserItem>('/users', payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: async (id: string, payload: UpdateUserPayload): Promise<UserItem> => {
|
||||||
|
return ApiClient.patch<any, UserItem>(`/users/${id}`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPassword: async (id: string, newPassword: string): Promise<{ success: boolean; message: string }> => {
|
||||||
|
return ApiClient.post<any, { success: boolean; message: string }>(`/users/${id}/reset-password`, {
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (id: string): Promise<{ success: boolean; message: string }> => {
|
||||||
|
return ApiClient.delete<any, { success: boolean; message: string }>(`/users/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersApi;
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Reset User Credentials"
|
||||||
|
description={`Set a new secure access password for ${user.email}.`}
|
||||||
|
size="sm"
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-end gap-3 w-full">
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="md"
|
||||||
|
onClick={onClose}
|
||||||
|
className="!rounded-[10px] !h-[40px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
leftIcon={<KeyIcon size={16} />}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||||
|
>
|
||||||
|
Update Password
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 py-1">
|
||||||
|
{error && (
|
||||||
|
<CustomAlertBanner
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-3.5 bg-amber-50/80 border border-amber-200/80 rounded-[12px] text-amber-800 text-[12px] leading-relaxed font-medium">
|
||||||
|
Setting a new password will immediately revoke active tokens and require the user to sign in with the new credentials.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<CustomInput
|
||||||
|
label="New Password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter at least 8 characters"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
leftIcon={<LockKeyIcon size={18} />}
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<RoleItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<CustomModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={userToEdit ? 'Edit User Profile' : 'Provision New User'}
|
||||||
|
description={
|
||||||
|
userToEdit
|
||||||
|
? 'Update account information and assigned operational clearance.'
|
||||||
|
: 'Provision a new team member with specific role credentials.'
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-end gap-3 w-full">
|
||||||
|
<CustomButton
|
||||||
|
variant="outlined"
|
||||||
|
size="md"
|
||||||
|
onClick={onClose}
|
||||||
|
className="!rounded-[10px] !h-[40px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
leftIcon={<FloppyDiskIcon size={16} />}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||||
|
>
|
||||||
|
{userToEdit ? 'Save Changes' : 'Provision User'}
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 py-1">
|
||||||
|
{error && (
|
||||||
|
<CustomAlertBanner
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<CustomInput
|
||||||
|
label="First Name"
|
||||||
|
required
|
||||||
|
placeholder="e.g. John"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
leftIcon={<UserIcon size={18} />}
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Last Name"
|
||||||
|
placeholder="e.g. Doe"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
leftIcon={<UserIcon size={18} />}
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomInput
|
||||||
|
label="Email Address"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
disabled={!!userToEdit}
|
||||||
|
placeholder="e.g. john.doe@aeroresolve.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
leftIcon={<EnvelopeSimpleIcon size={18} />}
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!userToEdit && (
|
||||||
|
<CustomInput
|
||||||
|
label="Temporary Password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
leftIcon={<LockKeyIcon size={18} />}
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CustomDropdown
|
||||||
|
label="Operational Role"
|
||||||
|
required
|
||||||
|
disabled={isSystemAdmin}
|
||||||
|
options={roleOptions}
|
||||||
|
value={roleId}
|
||||||
|
onChange={(val) => setRoleId(val)}
|
||||||
|
placeholder="Select Operational Role..."
|
||||||
|
leftIcon={<ShieldCheckIcon size={18} />}
|
||||||
|
size="md"
|
||||||
|
className="!h-[42px] !rounded-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-3.5 bg-gray-50/70 border border-gray-100 rounded-[12px] mt-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-[13px] font-semibold text-gray-900 block">
|
||||||
|
Account Status
|
||||||
|
</span>
|
||||||
|
<span className="text-[12px] text-gray-500 font-medium">
|
||||||
|
{isActive
|
||||||
|
? 'User can authenticate and access authorized workflows.'
|
||||||
|
: 'Account is deactivated and prohibited from signing in.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CustomSwitch
|
||||||
|
disabled={isSystemAdmin}
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[13px] font-semibold text-[#6C766D] tracking-[0px]">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
{rightIcon && <span className="text-[#6C766D]">{rightIcon}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryText({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryText({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="text-[12px] font-medium text-[#6C766D] leading-[16px] tracking-[0px] mt-0.5">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeLabel({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-xs font-semibold">
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersList() {
|
||||||
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pagination & Search
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||||
|
const [userToEdit, setUserToEdit] = useState<UserItem | null>(null);
|
||||||
|
|
||||||
|
const [resetModalOpen, setResetModalOpen] = useState(false);
|
||||||
|
const [userForReset, setUserForReset] = useState<UserItem | null>(null);
|
||||||
|
|
||||||
|
const [deleteConfirmUser, setDeleteConfirmUser] = useState<UserItem | null>(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<UserItem>[] = [
|
||||||
|
{
|
||||||
|
header: (
|
||||||
|
<CustomCheckBox
|
||||||
|
checked={users.length > 0 && selectedIds.size === users.length}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
className: 'w-[40px] pr-0',
|
||||||
|
accessor: (row) => (
|
||||||
|
<CustomCheckBox
|
||||||
|
checked={selectedIds.has(row.id)}
|
||||||
|
onChange={() => toggleSelectOne(row.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="User / Account" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PrimaryText text={row.fullName || row.email} />
|
||||||
|
{row.isSystem && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200">
|
||||||
|
<LockKeyIcon size={11} weight="bold" />
|
||||||
|
System Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SecondaryText text={row.email} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Operational Role" />,
|
||||||
|
accessor: (row) => <BadgeLabel text={row.role?.name || 'Unassigned'} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Status" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<CustomStatus
|
||||||
|
status={row.isActive ? 'Active' : 'Deactivated'}
|
||||||
|
variant={row.isActive ? 'success' : 'neutral'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Last Login" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<SecondaryText
|
||||||
|
text={row.lastLoginAt ? formatDate(row.lastLoginAt) : 'Never logged in'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Created Date" />,
|
||||||
|
accessor: (row) => <SecondaryText text={formatDate(row.createdAt)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: <HeaderLabel text="Action" />,
|
||||||
|
accessor: (row) => (
|
||||||
|
<div className="flex items-center pl-2">
|
||||||
|
<CustomActionMenu>
|
||||||
|
<Can permission="users:edit">
|
||||||
|
<CustomActionItem
|
||||||
|
onClick={() => handleEditUser(row)}
|
||||||
|
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
|
||||||
|
>
|
||||||
|
Edit User
|
||||||
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
<Can permission="users:reset_password">
|
||||||
|
<CustomActionItem
|
||||||
|
onClick={() => handleResetPassword(row)}
|
||||||
|
icon={<KeyIcon size={16} className="text-blue-500" />}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
|
||||||
|
{!row.isSystem && (
|
||||||
|
<Can permission="users:delete">
|
||||||
|
<CustomActionItem
|
||||||
|
variant="danger"
|
||||||
|
icon={<TrashIcon size={16} className="text-red-500" />}
|
||||||
|
onClick={() => setDeleteConfirmUser(row)}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</CustomActionItem>
|
||||||
|
</Can>
|
||||||
|
)}
|
||||||
|
</CustomActionMenu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||||
|
<Skeleton width={320} height={36} />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton width={148} height={36} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex flex-col gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} height={52} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-6">
|
||||||
|
{error && (
|
||||||
|
<CustomAlertBanner
|
||||||
|
message={error}
|
||||||
|
type="error"
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<CustomAlertBanner
|
||||||
|
message={successMsg}
|
||||||
|
type="success"
|
||||||
|
onClose={() => setSuccessMsg(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<CustomTable<UserItem>
|
||||||
|
columns={columns}
|
||||||
|
data={paginatedUsers}
|
||||||
|
leftHeaderActions={
|
||||||
|
<div className="w-[320px]">
|
||||||
|
<CustomInput
|
||||||
|
placeholder="Search users by name, email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
leftIcon={<MagnifyingGlassIcon size={16} />}
|
||||||
|
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
|
||||||
|
containerClassName="!gap-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rightHeaderActions={
|
||||||
|
<Can permission="users:create">
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
leftIcon={<PlusIcon size={16} />}
|
||||||
|
className="!rounded-[10px] !gap-[8px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||||
|
onClick={handleCreateUser}
|
||||||
|
>
|
||||||
|
Provision User
|
||||||
|
</CustomButton>
|
||||||
|
</Can>
|
||||||
|
}
|
||||||
|
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 */}
|
||||||
|
<UserModal
|
||||||
|
isOpen={userModalOpen}
|
||||||
|
userToEdit={userToEdit}
|
||||||
|
onClose={() => setUserModalOpen(false)}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
<ResetPasswordModal
|
||||||
|
isOpen={resetModalOpen}
|
||||||
|
user={userForReset}
|
||||||
|
onClose={() => setResetModalOpen(false)}
|
||||||
|
onSuccess={handleResetSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
<CustomConfirmationModal
|
||||||
|
isOpen={!!deleteConfirmUser}
|
||||||
|
onClose={() => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import UsersList from './components/UsersList';
|
||||||
|
|
||||||
|
export default function UsersManagementPage() {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<UsersList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CanProps> = ({
|
||||||
|
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;
|
||||||
@@ -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<ProtectedRouteProps> = ({
|
||||||
|
children,
|
||||||
|
permission,
|
||||||
|
permissions,
|
||||||
|
matchAny = true,
|
||||||
|
}) => {
|
||||||
|
const { isAuthenticated, isLoading, hasPermission, hasAnyPermission, hasAllPermissions } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-[#F4F7F6]">
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<div className="w-14 h-14 border-4 border-slate-200 border-t-primary rounded-full animate-spin"></div>
|
||||||
|
<div className="absolute w-7 h-7 bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<Shield className="w-4 h-4 text-primary animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs font-semibold uppercase tracking-widest text-slate-500 animate-pulse">
|
||||||
|
Authenticating Aero Resolve Session...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="min-h-[60vh] flex flex-col items-center justify-center p-6 text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-50 text-red-500 rounded-2xl flex items-center justify-center mb-4 shadow-sm">
|
||||||
|
<ShieldAlert size={32} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 tracking-tight">Access Restricted</h2>
|
||||||
|
<p className="text-sm text-slate-500 mt-1.5 max-w-md">
|
||||||
|
Your current role does not have the required security clearance to access this operational module.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 text-xs font-mono bg-slate-100 text-slate-600 px-3 py-1.5 rounded-lg border border-slate-200">
|
||||||
|
Required Clearance: {permission || permissions?.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
@@ -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<AuthUser>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshProfile: () => Promise<void>;
|
||||||
|
hasPermission: (permissionCode: string) => boolean;
|
||||||
|
hasAnyPermission: (permissionCodes: string[]) => boolean;
|
||||||
|
hasAllPermissions: (permissionCodes: string[]) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Verify active session on app boot
|
||||||
|
const refreshProfile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const profile = await ApiClient.get<any, AuthUser>('/auth/me');
|
||||||
|
setUser(profile);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshProfile();
|
||||||
|
}, [refreshProfile]);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string): Promise<AuthUser> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await ApiClient.post<any, { accessToken: string; user: AuthUser }>(
|
||||||
|
'/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 (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshProfile,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthContext;
|
||||||
@@ -10,6 +10,9 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
|||||||
'/action-builder': { title: 'Configuration - Action Builder', subtitle: 'Configure dynamic categories, action types, metadata fields, and dynamic user form workflows.' },
|
'/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.' },
|
'/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.' },
|
'/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 {
|
interface AppHeaderProps {
|
||||||
|
|||||||
+42
-15
@@ -11,17 +11,28 @@ import {
|
|||||||
GearIcon,
|
GearIcon,
|
||||||
ClockCounterClockwiseIcon,
|
ClockCounterClockwiseIcon,
|
||||||
CaretDoubleRightIcon,
|
CaretDoubleRightIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
LockKeyIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { ShieldCheckIcon } from "lucide-react";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
interface NavItem {
|
||||||
{ label: "Dashboard", path: "/", icon: SquaresFourIcon },
|
label: string;
|
||||||
{ label: "Simulation Engine", path: "/simulation", icon: FadersIcon },
|
path: string;
|
||||||
{ label: "Recovery Incidents", path: "/recovery", icon: ArrowsClockwiseIcon },
|
icon: any;
|
||||||
{ label: "Cohort Management", path: "/cohorts", icon: UsersFourIcon },
|
permission?: string;
|
||||||
{ label: "Policy Engine", path: "/policy-engine", icon: ShieldCheckIcon },
|
}
|
||||||
{ label: "Configuration", path: "/config", icon: GearIcon },
|
|
||||||
{ label: "Audit Logs", path: "/audit-logs", icon: ClockCounterClockwiseIcon },
|
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 {
|
interface AppSidebarProps {
|
||||||
@@ -32,6 +43,19 @@ interface AppSidebarProps {
|
|||||||
export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -95,7 +119,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
|||||||
<nav
|
<nav
|
||||||
className={`flex-1 py-3 space-y-1 overflow-y-auto overflow-x-hidden ${isCollapsed ? "px-3" : "px-4"}`}
|
className={`flex-1 py-3 space-y-1 overflow-y-auto overflow-x-hidden ${isCollapsed ? "px-3" : "px-4"}`}
|
||||||
>
|
>
|
||||||
{NAV_ITEMS.map((item) => {
|
{visibleNavItems.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
location.pathname === item.path ||
|
location.pathname === item.path ||
|
||||||
(item.path !== "/" && location.pathname.startsWith(item.path));
|
(item.path !== "/" && location.pathname.startsWith(item.path));
|
||||||
@@ -141,8 +165,9 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
|||||||
className={`relative z-10 flex flex-col ${isCollapsed ? "gap-2 w-full" : "gap-0.5"}`}
|
className={`relative z-10 flex flex-col ${isCollapsed ? "gap-2 w-full" : "gap-0.5"}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
onClick={logout}
|
||||||
title={isCollapsed ? "Sign Out" : undefined}
|
title={isCollapsed ? "Sign Out" : undefined}
|
||||||
className={`flex items-center ${isCollapsed ? "justify-center px-0 h-10 w-full" : "gap-3 px-3 py-2"} text-[13px] font-semibold text-[#E02424] hover:bg-red-50/50 rounded-[10px] transition-colors`}
|
className={`flex items-center ${isCollapsed ? "justify-center px-0 h-10 w-full" : "gap-3 px-3 py-2"} text-[13px] font-semibold text-[#E02424] hover:bg-red-50/50 rounded-[10px] transition-colors cursor-pointer`}
|
||||||
>
|
>
|
||||||
<SignOutIcon
|
<SignOutIcon
|
||||||
size={17}
|
size={17}
|
||||||
@@ -168,17 +193,19 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
title={isCollapsed ? "Admin Demo" : undefined}
|
title={isCollapsed ? userDisplayName : undefined}
|
||||||
className={`p-2 bg-white rounded-[16px] shadow-[0_2px_8px_-4px_rgba(0,0,0,0.08)] border border-white flex items-center cursor-pointer hover:shadow-md transition-shadow ${isCollapsed ? "justify-center mt-1 w-full h-12" : "gap-2.5 mt-2"}`}
|
className={`p-2 bg-white rounded-[16px] shadow-[0_2px_8px_-4px_rgba(0,0,0,0.08)] border border-white flex items-center cursor-pointer hover:shadow-md transition-shadow ${isCollapsed ? "justify-center mt-1 w-full h-12" : "gap-2.5 mt-2"}`}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-full bg-[#C2D1E0] flex-shrink-0" />
|
<div className="w-8 h-8 rounded-full bg-[#C2D1E0] flex items-center justify-center text-slate-700 font-bold text-xs flex-shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex flex-col justify-center overflow-hidden">
|
<div className="flex flex-col justify-center overflow-hidden">
|
||||||
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">
|
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">
|
||||||
Admin Demo
|
{userDisplayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">
|
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">
|
||||||
System Administrator
|
{roleDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user