feat: implement authentication module with login page and protected routes while scaffolding application layout and roles management components
This commit is contained in:
Generated
-10
@@ -12,7 +12,6 @@
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.18.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-icons": "^5.6.0",
|
||||
@@ -2842,15 +2841,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
|
||||
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
+1
-2
@@ -23,7 +23,6 @@
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.18.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-icons": "^5.6.0",
|
||||
@@ -48,4 +47,4 @@
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,232 @@
|
||||
function HomePage() {
|
||||
return (
|
||||
<section className="page home-page">
|
||||
<h1>Home Page</h1>
|
||||
<p>Welcome to the home page. Use the navigation links to switch pages.</p>
|
||||
</section>
|
||||
)
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../../context/AuthContext';
|
||||
import {
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomAlertBanner,
|
||||
} from '../../../components/custom';
|
||||
import {
|
||||
EnvelopeSimpleIcon,
|
||||
LockKeyIcon,
|
||||
ArrowRightIcon,
|
||||
SparkleIcon,
|
||||
ShieldCheckIcon,
|
||||
InfoIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
interface SiginProps {
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
export default function Sigin({ onSuccess, className = '' }: SiginProps) {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForgotNotice, setShowForgotNotice] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError('Please provide both your enterprise email and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
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 and network access.';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickFill = () => {
|
||||
setEmail('admin@aeroresolve.com');
|
||||
setPassword('Password@123');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-md mx-auto space-y-6 ${className}`}>
|
||||
{/* Brand Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-[42px] h-[42px] bg-[#4B4B4B] rounded-[14px] flex flex-col items-center justify-center text-white shadow-sm shrink-0">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mb-0.5"
|
||||
>
|
||||
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.2-1.1.7l-1.2 3.3c-.2.5.1 1.1.6 1.2l6.9 1.7-2.9 2.9-3.6-.9c-.5-.1-.9.2-1.1.7l-1.3 3.5c-.2.5.1 1.1.6 1.2l12.4 3.1c.5.1.9-.2 1.1-.7l.8-2.3c.1-.5-.2-1.1-.7-1.2z" />
|
||||
</svg>
|
||||
<div className="w-[18px] h-[2px] bg-white rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[18px] font-extrabold text-[#0F172B] tracking-tight whitespace-nowrap">
|
||||
Aero Resolve
|
||||
</span>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-full bg-[#E8F3EF] text-[#1E7D5C] border border-emerald-100">
|
||||
Enterprise
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#64748B]">Disruption Management & Recovery</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-extrabold text-[#0F172B] tracking-tight">
|
||||
Sign In to Terminal
|
||||
</h2>
|
||||
<p className="text-sm text-[#64748B] mt-1">
|
||||
Enter your enterprise credentials to access operational control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Credentials Quick Fill Helper */}
|
||||
<div className="p-3.5 rounded-[12px] bg-[#E8F3EF]/60 border border-emerald-200/60 flex items-center justify-between gap-3 shadow-xs">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#1E7D5C]/10 flex items-center justify-center text-[#1E7D5C] shrink-0">
|
||||
<SparkleIcon size={16} weight="fill" />
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<span className="font-semibold text-[#0F172B] block">Super Admin Demo</span>
|
||||
<span className="text-[#64748B] text-[11px] font-mono">admin@aeroresolve.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickFill}
|
||||
className="px-3 py-1.5 text-xs font-bold text-[#1E7D5C] bg-white hover:bg-emerald-50 border border-emerald-200 rounded-[8px] transition-colors cursor-pointer shadow-xs"
|
||||
>
|
||||
Auto Fill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
autoClose={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Forgot Password Modal / Alert Notice */}
|
||||
{showForgotNotice && (
|
||||
<div className="p-3.5 rounded-[12px] bg-blue-50 border border-blue-200 text-blue-900 text-xs flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<InfoIcon size={16} className="text-blue-600 mt-0.5 shrink-0" />
|
||||
<p>
|
||||
For security compliance, password resets require airline system administrator approval. Contact your IT operations lead at <span className="font-semibold font-mono">support@aeroresolve.com</span>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotNotice(false)}
|
||||
className="text-blue-500 hover:text-blue-800 font-bold px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<CustomInput
|
||||
label="Email Address"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="e.g. name@aeroresolve.com"
|
||||
leftIcon={<EnvelopeSimpleIcon size={18} className="text-gray-400" />}
|
||||
className="!h-[44px] !rounded-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<CustomInput
|
||||
label="Password"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
leftIcon={<LockKeyIcon size={18} className="text-gray-400" />}
|
||||
className="!h-[44px] !rounded-[10px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remember me & Forgot Password */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<CustomCheckBox
|
||||
label="Remember this workstation"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotNotice(true)}
|
||||
className="text-xs font-semibold text-[#1E7D5C] hover:text-[#14704E] hover:underline transition-colors cursor-pointer"
|
||||
>
|
||||
Forgot Password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-2">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
disabled={loading || !email.trim() || !password.trim()}
|
||||
rightIcon={<ArrowRightIcon size={18} weight="bold" />}
|
||||
className="!w-full !h-[46px] !rounded-[12px] !bg-[#1B9869] hover:!bg-[#14704E] font-bold text-sm shadow-sm transition-all"
|
||||
>
|
||||
{loading ? 'Authenticating...' : 'Sign In to Terminal'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Security Footer */}
|
||||
<div className="pt-3 border-t border-gray-100 flex items-center justify-center gap-2 text-[12px] text-[#64748B]">
|
||||
<ShieldCheckIcon size={17} weight="fill" className="text-[#1E7D5C]" />
|
||||
<span>Secured via Role-Based Access Control (RBAC)</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Sigin from './components/Sigin';
|
||||
import {
|
||||
Lock,
|
||||
Mail,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ShieldCheck,
|
||||
Plane,
|
||||
KeyRound,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
ShieldCheckIcon,
|
||||
AirplaneTiltIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
const { 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 already authenticated, redirect to destination or root
|
||||
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">
|
||||
<div className="min-h-screen w-full flex flex-col lg:flex-row bg-[#F4F7F6] text-[#0F172B] font-sans">
|
||||
{/* 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" />
|
||||
<div className="relative hidden lg:flex lg:w-1/2 xl:w-7/12 flex-col justify-between p-12 overflow-hidden bg-gradient-to-br from-[#0F172A] via-[#111C33] to-[#0A101D] text-slate-100">
|
||||
{/* Background glow and subtle aviation radar circles */}
|
||||
<div className="absolute top-1/4 -left-20 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-10 right-10 w-96 h-96 bg-teal-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(#334155_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" />
|
||||
{/* Top Brand Logo */}
|
||||
<div className="relative z-10 flex items-center gap-3.5">
|
||||
<div className="w-[46px] h-[46px] bg-[#4B4B4B] rounded-[14px] flex flex-col items-center justify-center text-white shadow-lg shadow-black/20 shrink-0 border border-white/10">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mb-0.5"
|
||||
>
|
||||
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.2-1.1.7l-1.2 3.3c-.2.5.1 1.1.6 1.2l6.9 1.7-2.9 2.9-3.6-.9c-.5-.1-.9.2-1.1.7l-1.3 3.5c-.2.5.1 1.1.6 1.2l12.4 3.1c.5.1.9-.2 1.1-.7l.8-2.3c.1-.5-.2-1.1-.7-1.2z" />
|
||||
</svg>
|
||||
<div className="w-[20px] h-[2px] bg-white rounded-full" />
|
||||
</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">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-extrabold tracking-tight text-white">
|
||||
AERO RESOLVE
|
||||
</h1>
|
||||
<span className="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
Enterprise
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 font-medium">
|
||||
Aviation Passenger Recovery & Compensation Intelligence
|
||||
</p>
|
||||
@@ -92,161 +64,63 @@ export default function LoginPage() {
|
||||
</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)
|
||||
<div className="relative z-10 my-auto max-w-xl space-y-6">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-white/5 border border-white/10 backdrop-blur-md">
|
||||
<ShieldCheckIcon size={18} weight="fill" className="text-emerald-400" />
|
||||
<span className="text-xs font-semibold text-slate-200 tracking-wide">
|
||||
Multi-Jurisdiction Regulatory Decision Framework
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-white leading-tight tracking-tight">
|
||||
<h2 className="text-3xl xl:text-4xl font-black 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 className="text-sm xl:text-base text-slate-300 leading-relaxed font-normal">
|
||||
Automate passenger cohort segmentation, statutory compensation (EU261, US DOT, APPR, UK261),
|
||||
goodwill policies, and live manifest recovery simulation in real time.
|
||||
</p>
|
||||
|
||||
{/* Operational Feature Badges */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-white/[0.04] border border-white/10">
|
||||
<CheckCircleIcon size={20} weight="fill" className="text-emerald-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-200">
|
||||
Dynamic Policy Rule Engine
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-white/[0.04] border border-white/10">
|
||||
<AirplaneTiltIcon size={20} weight="fill" className="text-emerald-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-slate-200">
|
||||
Real-Time Incident Recovery
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<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-2xl font-extrabold 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>
|
||||
<span className="text-2xl font-extrabold text-emerald-400 block">< 150ms</span>
|
||||
<span className="text-xs text-slate-400 font-medium">Evaluation Latency</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">
|
||||
<div className="relative z-10 flex items-center justify-between text-xs text-slate-500 pt-6 border-t border-slate-800/60 font-medium">
|
||||
<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 className="w-full lg:w-1/2 xl:w-5/12 flex flex-col justify-center items-center p-6 sm:p-10 lg:p-12 bg-white">
|
||||
<div className="w-full max-w-md">
|
||||
<Sigin />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon } from '@phosphor-icons/react';
|
||||
import { User, Checks, X, ClockCounterClockwiseIcon, ArrowLeftIcon, ArrowsClockwiseIcon, AirplaneTiltIcon, SparkleIcon } from '@phosphor-icons/react';
|
||||
import { CustomButton, CustomTabs, CustomBackButton, CustomStatus, Skeleton, CustomAlertBanner } from '../../../components/custom';
|
||||
import SummaryTab from './SummaryTab';
|
||||
import CaseDetailsTab from './CaseDetailsTab';
|
||||
import RecoveryPlanTab from './RecoveryPlanTab';
|
||||
import AuditTrailTab from './AuditTrailTab';
|
||||
import { SparkleIcon } from 'lucide-react';
|
||||
import { getRecoveryIncident, updateIncidentStatus, reRunPolicyEngine } from '../RecoveryIncidentsApi';
|
||||
import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
|
||||
|
||||
@@ -254,7 +253,7 @@ export default function RecoveryIncidentTabs() {
|
||||
{/* Sidebar Content */}
|
||||
<div className="p-6 select-none pointer-events-none">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="text-[#1B9869]"><SparkleIcon size={20} height="fill" /></span>
|
||||
<span className="text-[#1B9869]"><SparkleIcon size={20} weight="fill" /></span>
|
||||
<h3 className="text-[13px] font-bold text-gray-400 tracking-wider">AI RECOMMENDATION</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomTextArea,
|
||||
CustomAlertBanner,
|
||||
CustomSuccessModal,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
LockKeyIcon,
|
||||
ArrowLeftIcon,
|
||||
FloppyDiskIcon,
|
||||
SquaresFourIcon,
|
||||
LockKeyIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
interface AddRolesProps {
|
||||
@@ -29,6 +29,8 @@ export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [successTitle, setSuccessTitle] = useState('');
|
||||
|
||||
const isSystemRole = roleToEdit?.isSystem ?? false;
|
||||
|
||||
@@ -103,8 +105,8 @@ export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps)
|
||||
setSelectedPermissionIds(new Set());
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setError('Role name is required.');
|
||||
return;
|
||||
@@ -126,14 +128,16 @@ export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps)
|
||||
description: description.trim(),
|
||||
permissionIds,
|
||||
});
|
||||
setSuccessTitle('Role Updated Successfully.');
|
||||
} else {
|
||||
await RolesApi.createRole({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
permissionIds,
|
||||
});
|
||||
setSuccessTitle('Role Created Successfully.');
|
||||
}
|
||||
onSaved();
|
||||
setShowSuccessModal(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || 'Failed to save role.');
|
||||
} finally {
|
||||
@@ -141,215 +145,312 @@ export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps)
|
||||
}
|
||||
};
|
||||
|
||||
const CardHeader = ({ icon: Icon, title }: { icon: any; title: string }) => (
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#E8F3EF] flex items-center justify-center text-[#1E7D5C]">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-[#0F172B]">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 className="flex flex-col h-full bg-white min-h-screen">
|
||||
{/* ─── Header Skeleton ────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-1 p-1">
|
||||
<Skeleton variant="circular" className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="w-48 h-7" />
|
||||
<Skeleton className="w-32 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Body Content Skeleton ──────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{/* Role Information Card Skeleton */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Skeleton className="w-8 h-8 rounded-lg" />
|
||||
<Skeleton className="w-40 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="w-24 h-4" />
|
||||
<Skeleton className="w-full h-11" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="w-24 h-4" />
|
||||
<Skeleton className="w-full h-11" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="w-24 h-4" />
|
||||
<Skeleton className="w-full h-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Matrix Card Skeleton */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-8 h-8 rounded-lg" />
|
||||
<Skeleton className="w-48 h-5" />
|
||||
</div>
|
||||
<Skeleton className="w-32 h-9 rounded-lg" />
|
||||
</div>
|
||||
<div className="border border-gray-100 rounded-[12px] p-6 bg-white space-y-4">
|
||||
<Skeleton className="w-48 h-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
<Skeleton className="w-full h-20 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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} />}
|
||||
<div className="flex flex-col h-full bg-white min-h-screen">
|
||||
{/* ─── Header ────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="!rounded-[10px] !h-[38px]"
|
||||
className="mt-1 p-1 hover:bg-gray-100 rounded-full transition-colors text-gray-500"
|
||||
>
|
||||
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>
|
||||
<ArrowLeftIcon size={20} />
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-bold text-[#0F172B]">
|
||||
{roleToEdit ? (isSystemRole ? 'View System Role' : 'Edit Role') : 'Create Custom Role'}
|
||||
</h1>
|
||||
<span className="text-[13px] text-gray-500">Global Framework Registry</span>
|
||||
</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]"
|
||||
{/* ─── Body Content ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* Role Information Card */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<CardHeader icon={LockKeyIcon} title="Role Information" />
|
||||
|
||||
{/* 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 className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomInput
|
||||
label="Role Name"
|
||||
required
|
||||
disabled={isSystemRole}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Flight Disruption Lead"
|
||||
className="!h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-gray-700">Role Type</label>
|
||||
<div className="flex items-center gap-6 h-11">
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-semibold ${
|
||||
isSystemRole
|
||||
? 'bg-amber-50 text-amber-700 border border-amber-200'
|
||||
: 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
}`}
|
||||
>
|
||||
{isSystemRole ? 'System Role (Immutable)' : 'Custom Role'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomTextArea
|
||||
label="Description"
|
||||
disabled={isSystemRole}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="e.g. Manages passenger goodwill offers and compensation evaluations"
|
||||
className="!h-24 resize-none"
|
||||
/>
|
||||
</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));
|
||||
{/* Granular Permissions Matrix Card */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardHeader icon={ShieldCheckIcon} title="Granular Permissions Matrix" />
|
||||
<span className="text-xs font-semibold text-[#1E7D5C] bg-[#E8F3EF] px-2.5 py-1 rounded-full border border-emerald-100 mb-5">
|
||||
{selectedPermissionIds.size} Selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
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>
|
||||
{!isSystemRole && (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-emerald-50/50 font-semibold !h-9 text-xs"
|
||||
>
|
||||
Select All
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={deselectAll}
|
||||
className="!border-gray-300 !text-gray-600 hover:!bg-gray-50 font-semibold !h-9 text-xs"
|
||||
>
|
||||
Deselect All
|
||||
</CustomButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{Object.entries(groupedPermissions).map(([groupName, perms]) => {
|
||||
const groupIds = perms.map((p) => p.id);
|
||||
const allGroupSelected = groupIds.length > 0 && groupIds.every((id) => selectedPermissionIds.has(id));
|
||||
const selectedInGroupCount = perms.filter((p) => selectedPermissionIds.has(p.id)).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupName}
|
||||
className="border border-gray-100 rounded-[12px] p-6 bg-white relative transition-all shadow-sm"
|
||||
>
|
||||
{/* Module Header */}
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomCheckBox
|
||||
disabled={isSystemRole}
|
||||
checked={allGroupSelected}
|
||||
onChange={() => toggleGroup(groupName, perms)}
|
||||
/>
|
||||
<h3 className="text-base font-bold text-[#0F172B]">{groupName}</h3>
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#1E7D5C] bg-[#E8F3EF] px-3 py-1 rounded-full border border-emerald-100">
|
||||
{selectedInGroupCount} / {perms.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Permission Cards Grid */}
|
||||
<div className="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.5 rounded-[10px] border transition-all cursor-pointer select-none flex items-start gap-3 ${
|
||||
isSelected
|
||||
? 'bg-emerald-50/80 border-emerald-300 shadow-sm'
|
||||
: 'bg-white border-gray-200 hover:border-gray-300 hover:bg-gray-50/50'
|
||||
} ${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-1 overflow-hidden flex-1">
|
||||
<span className="text-xs font-bold text-[#0F172B] block truncate">
|
||||
{perm.name}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-[#6C766D] bg-gray-100 px-1.5 py-0.5 rounded inline-block truncate max-w-full">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer save buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
{/* ─── Sticky Footer ─────────────────────────────────────────────── */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-8 py-4 flex items-center justify-between z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[14px] font-semibold text-gray-600">Permissions Selected:</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-[#E8F3EF] text-[#1E7D5C]">
|
||||
{selectedPermissionIds.size} Active
|
||||
</span>
|
||||
{isSystemRole && (
|
||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200 font-medium">
|
||||
System Role (Read-only)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="md"
|
||||
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
|
||||
onClick={onBack}
|
||||
className="!rounded-[10px] !h-[40px]"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
{!isSystemRole && (
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<FloppyDiskIcon size={16} />}
|
||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={loading || !name.trim() || selectedPermissionIds.size === 0}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
className="!rounded-[10px] !h-[40px] !bg-[#1B9869] hover:!bg-[#14704E]"
|
||||
>
|
||||
{roleToEdit ? 'Save Changes' : 'Create Role'}
|
||||
{loading ? 'Saving...' : roleToEdit ? 'Save Changes' : 'Create Role'}
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Success Modal */}
|
||||
<CustomSuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
onClose={() => {
|
||||
setShowSuccessModal(false);
|
||||
onSaved();
|
||||
}}
|
||||
title={successTitle}
|
||||
label="ROLE NAME"
|
||||
cohortName={name}
|
||||
cohortDescription={description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { RolesApi, type RoleItem } from '../RolesApi';
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomAlertBanner,
|
||||
CustomConfirmationModal,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomCheckBox,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import type { Column } from '../../../components/custom/CustomTable';
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
PlusIcon,
|
||||
@@ -18,6 +23,26 @@ import {
|
||||
MagnifyingGlassIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface RolesListProps {
|
||||
onAddRole: () => void;
|
||||
@@ -28,27 +53,35 @@ 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();
|
||||
}, []);
|
||||
// Pagination & Search
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const loadRoles = async () => {
|
||||
// Row selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Delete modal state
|
||||
const [deleteConfirmRole, setDeleteConfirmRole] = useState<RoleItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadRoles = useCallback(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');
|
||||
setError(err?.response?.data?.message || 'Failed to load roles list.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirmRole) return;
|
||||
@@ -65,6 +98,7 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter & Pagination
|
||||
const filteredRoles = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return roles;
|
||||
@@ -76,6 +110,161 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
|
||||
);
|
||||
}, [roles, search]);
|
||||
|
||||
const totalItems = filteredRoles.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 paginatedRoles = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredRoles.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredRoles, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearch(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Row selection handlers
|
||||
const toggleSelectAll = () => {
|
||||
if (paginatedRoles.length > 0 && selectedIds.size === paginatedRoles.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(paginatedRoles.map((r) => r.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectOne = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Table columns definition
|
||||
const columns: Column<RoleItem>[] = [
|
||||
{
|
||||
header: (
|
||||
<CustomCheckBox
|
||||
checked={paginatedRoles.length > 0 && selectedIds.size === paginatedRoles.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="Role Name" />,
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-[10px] flex items-center justify-center font-bold text-sm shrink-0 ${
|
||||
row.isSystem
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-emerald-100 text-[#1B9869]'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheckIcon size={20} weight="bold" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px]">
|
||||
{row.name}
|
||||
</span>
|
||||
{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 shrink-0">
|
||||
<LockKeyIcon size={11} weight="bold" />
|
||||
System Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-[#6C766D] block mt-0.5">
|
||||
{row.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Description" />,
|
||||
accessor: (row) => (
|
||||
<p className="text-[12px] font-medium text-[#6C766D] line-clamp-2 max-w-sm leading-relaxed">
|
||||
{row.description || 'No description configured for this operational role.'}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Permissions" />,
|
||||
accessor: (row) => (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-[#E8F3EF] text-[#1E7D5C] border border-emerald-100">
|
||||
<KeyIcon size={13} weight="bold" />
|
||||
{row.permissionCount} rules
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Assigned Users" />,
|
||||
accessor: (row) => (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-100">
|
||||
<UsersIcon size={13} weight="bold" />
|
||||
{row.userCount} users
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Created At" />,
|
||||
accessor: (row) => (
|
||||
<span className="text-[12px] font-medium text-[#6C766D]">
|
||||
{row.createdAt ? formatDate(row.createdAt) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center pl-2">
|
||||
<CustomActionMenu>
|
||||
<Can permission="roles:view">
|
||||
<CustomActionItem
|
||||
onClick={() => onEditRole(row)}
|
||||
icon={<PencilSimpleIcon size={16} className="text-yellow-500" />}
|
||||
>
|
||||
{row.isSystem ? 'View Permissions' : 'Edit Role'}
|
||||
</CustomActionItem>
|
||||
</Can>
|
||||
|
||||
{!row.isSystem && (
|
||||
<Can permission="roles:delete">
|
||||
<CustomActionItem
|
||||
variant="danger"
|
||||
icon={<TrashIcon size={16} className="text-red-500" />}
|
||||
onClick={() => setDeleteConfirmRole(row)}
|
||||
>
|
||||
Delete Role
|
||||
</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">
|
||||
@@ -85,9 +274,9 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
|
||||
<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 className="p-6 flex flex-col gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height={52} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,137 +300,44 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Standardized Table Section */}
|
||||
<CustomTable<RoleItem>
|
||||
columns={columns}
|
||||
data={paginatedRoles}
|
||||
leftHeaderActions={
|
||||
<div className="w-[320px]">
|
||||
<CustomInput
|
||||
placeholder="Search roles by name, slug..."
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
rightHeaderActions={
|
||||
<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>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={handlePageChange}
|
||||
itemName="Roles"
|
||||
rowClassName={() => 'bg-white border-b border-gray-100 hover:bg-gray-50/60'}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<CustomConfirmationModal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { ShieldAlert, Shield } from 'lucide-react';
|
||||
import { ShieldWarningIcon, ShieldCheckIcon } from '@phosphor-icons/react';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
@@ -25,7 +25,7 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
<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" />
|
||||
<ShieldCheckIcon 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">
|
||||
@@ -51,7 +51,7 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
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} />
|
||||
<ShieldWarningIcon 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">
|
||||
|
||||
+120
-50
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
SquaresFourIcon,
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
CaretDoubleRightIcon,
|
||||
ShieldCheckIcon,
|
||||
LockKeyIcon,
|
||||
CaretUpIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
icon: any;
|
||||
icon: React.ElementType;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
@@ -43,8 +44,34 @@ interface AppSidebarProps {
|
||||
export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
const location = useLocation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout, hasPermission } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMenuOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
const visibleNavItems = NAV_ITEMS.filter((item) => {
|
||||
if (!item.permission) return true;
|
||||
return hasPermission(item.permission);
|
||||
@@ -104,7 +131,10 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
<CaretDoubleLeftIcon size={20} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
setIsCollapsed(!isCollapsed);
|
||||
}}
|
||||
className={`hidden lg:block text-slate-400 hover:text-slate-600 transition-colors ${isCollapsed ? "" : "ml-auto"}`}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
@@ -131,6 +161,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
to={item.path}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
if (window.innerWidth < 1024) onClose();
|
||||
}}
|
||||
className={`group flex items-center ${isCollapsed ? "justify-center px-0 w-12 mx-auto" : "gap-3 px-3.5"} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${isActive
|
||||
@@ -152,62 +183,101 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section Card */}
|
||||
{/* Bottom Section Card Container with Dropup Menu */}
|
||||
<div
|
||||
className={`mb-2 mt-2 bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[24px] shadow-[0_4px_12px_-4px_rgba(0,0,0,0.05)] relative overflow-hidden transition-all duration-300 ${isCollapsed ? "mx-2 p-2 flex flex-col items-center gap-3" : "mx-4 p-3"}`}
|
||||
ref={menuRef}
|
||||
className={`relative mb-2 mt-2 transition-all duration-300 ${
|
||||
isCollapsed ? "mx-2" : "mx-4"
|
||||
}`}
|
||||
>
|
||||
{/* Soft decorative glow */}
|
||||
{!isCollapsed && (
|
||||
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Dropup Menu (Opens Upwards with transition) */}
|
||||
<div
|
||||
className={`relative z-10 flex flex-col ${isCollapsed ? "gap-2 w-full" : "gap-0.5"}`}
|
||||
className={`absolute bottom-full mb-2 ${
|
||||
isCollapsed ? "left-0 w-48" : "left-0 right-0"
|
||||
} z-50 bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[20px] shadow-[0_12px_28px_-4px_rgba(0,0,0,0.12),0_4px_10px_-2px_rgba(0,0,0,0.06)] p-2 transition-all duration-200 ease-out origin-bottom transform ${
|
||||
isMenuOpen
|
||||
? "opacity-100 translate-y-0 scale-100 pointer-events-auto"
|
||||
: "opacity-0 translate-y-2 scale-95 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={logout}
|
||||
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 cursor-pointer`}
|
||||
>
|
||||
<SignOutIcon
|
||||
size={17}
|
||||
className="text-[#E02424] shrink-0"
|
||||
weight="bold"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span className="whitespace-nowrap">Sign Out</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
title={isCollapsed ? "Help Center" : undefined}
|
||||
className={`flex items-center ${isCollapsed ? "justify-center px-0 h-10 w-full" : "gap-3 px-3 py-2"} text-[13px] font-medium text-slate-700 hover:bg-white/40 rounded-[10px] transition-colors`}
|
||||
>
|
||||
<QuestionIcon
|
||||
size={17}
|
||||
className="text-slate-700 shrink-0"
|
||||
weight="regular"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
title="Help Center"
|
||||
className="flex items-center gap-3 px-3 py-2 text-[13px] font-medium text-slate-700 hover:bg-white/60 rounded-[12px] transition-colors w-full cursor-pointer text-left"
|
||||
>
|
||||
<QuestionIcon
|
||||
size={17}
|
||||
className="text-slate-700 shrink-0"
|
||||
weight="regular"
|
||||
/>
|
||||
<span className="whitespace-nowrap">Help center</span>
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
logout();
|
||||
}}
|
||||
title="Sign Out"
|
||||
className="flex items-center gap-3 px-3 py-2 text-[13px] font-semibold text-[#E02424] hover:bg-red-50/70 rounded-[12px] transition-colors w-full cursor-pointer text-left"
|
||||
>
|
||||
<SignOutIcon
|
||||
size={17}
|
||||
className="text-[#E02424] shrink-0"
|
||||
weight="bold"
|
||||
/>
|
||||
<span className="whitespace-nowrap">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Profile Card (Clickable Trigger) */}
|
||||
<div
|
||||
onClick={() => setIsMenuOpen((prev) => !prev)}
|
||||
title={isCollapsed ? userDisplayName : undefined}
|
||||
className={`bg-gradient-to-br from-[#FAFCFB] to-[#E3EFE9] border border-white rounded-[24px] shadow-[0_4px_12px_-4px_rgba(0,0,0,0.05)] hover:shadow-md relative overflow-hidden transition-all duration-300 cursor-pointer select-none ${
|
||||
isCollapsed
|
||||
? "p-2 flex items-center justify-center h-12"
|
||||
: "p-3 flex items-center justify-between"
|
||||
} ${isMenuOpen ? "ring-2 ring-primary/20 shadow-md" : ""}`}
|
||||
>
|
||||
{/* Soft decorative glow */}
|
||||
{!isCollapsed && (
|
||||
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />
|
||||
)}
|
||||
|
||||
<div
|
||||
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={`relative z-10 flex items-center ${
|
||||
isCollapsed ? "justify-center w-full" : "justify-between w-full gap-2.5"
|
||||
}`}
|
||||
>
|
||||
<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 && (
|
||||
<div className="flex flex-col justify-center overflow-hidden">
|
||||
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">
|
||||
{userDisplayName}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">
|
||||
{roleDisplayName}
|
||||
</span>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-[#C2D1E0] flex items-center justify-center text-slate-700 font-bold text-xs shrink-0 shadow-sm">
|
||||
{initials}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col justify-center overflow-hidden min-w-0">
|
||||
<span className="text-[13px] font-bold text-[#111827] leading-tight truncate">
|
||||
{userDisplayName}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium mt-0.5 leading-tight truncate">
|
||||
{roleDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<CaretUpIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className={`text-slate-400 shrink-0 transition-transform duration-200 ${
|
||||
isMenuOpen ? "rotate-0 text-slate-700" : "rotate-180"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user