Merge pull request 'feat: implement authentication module with login page and protected routes while scaffolding application layout and roles management components' (#64) from waseem into development

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/64
This commit is contained in:
Syed Waseem khadri Rafai
2026-09-10 06:44:10 +00:00
9 changed files with 944 additions and 593 deletions
-10
View File
@@ -12,7 +12,6 @@
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"lucide-react": "^1.23.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
@@ -2842,15 +2841,6 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1 -2
View File
@@ -23,7 +23,6 @@
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"lucide-react": "^1.23.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
@@ -48,4 +47,4 @@
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^8.0.12" "vite": "^8.0.12"
} }
} }
+230 -8
View File
@@ -1,10 +1,232 @@
function HomePage() { import React, { useState } from 'react';
return ( import { useNavigate, useLocation } from 'react-router-dom';
<section className="page home-page"> import { useAuth } from '../../../context/AuthContext';
<h1>Home Page</h1> import {
<p>Welcome to the home page. Use the navigation links to switch pages.</p> CustomInput,
</section> 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>
);
}
+71 -197
View File
@@ -1,90 +1,62 @@
import React, { useState } from 'react'; import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import Sigin from './components/Sigin';
import { import {
Lock, ShieldCheckIcon,
Mail, AirplaneTiltIcon,
Eye, CheckCircleIcon,
EyeOff, } from '@phosphor-icons/react';
ShieldCheck,
Plane,
KeyRound,
Sparkles,
ArrowRight,
AlertCircle,
} from 'lucide-react';
export default function LoginPage() { export default function LoginPage() {
const { login, isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [email, setEmail] = useState(''); // If already authenticated, redirect to destination or root
const [password, setPassword] = useState(''); useEffect(() => {
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) { if (isAuthenticated) {
const from = (location.state as any)?.from?.pathname || '/'; const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true }); navigate(from, { replace: true });
} }
}, [isAuthenticated, navigate, location]); }, [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 ( 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 */} {/* 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]"> <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 & radar circles */} {/* Background glow and subtle aviation 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 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-blue-600/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(#1e293b_1px,transparent_1px)] [background-size:24px_24px] opacity-25 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 */} {/* Top Brand Logo */}
<div className="relative z-10 flex items-center gap-3"> <div className="relative z-10 flex items-center gap-3.5">
<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"> <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">
<Plane size={24} className="rotate-45" /> <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>
<div> <div>
<h1 className="text-xl font-black tracking-tight text-white flex items-center gap-2"> <div className="flex items-center gap-2">
AERO RESOLVE <h1 className="text-xl font-extrabold tracking-tight text-white">
<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"> 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 Enterprise
</span> </span>
</h1> </div>
<p className="text-xs text-slate-400 font-medium"> <p className="text-xs text-slate-400 font-medium">
Aviation Passenger Recovery & Compensation Intelligence Aviation Passenger Recovery & Compensation Intelligence
</p> </p>
@@ -92,161 +64,63 @@ export default function LoginPage() {
</div> </div>
{/* Hero Central Content */} {/* Hero Central Content */}
<div className="relative z-10 my-auto max-w-lg space-y-6"> <div className="relative z-10 my-auto max-w-xl 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"> <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">
<ShieldCheck className="w-4 h-4 text-teal-400" /> <ShieldCheckIcon size={18} weight="fill" className="text-emerald-400" />
<span className="text-xs font-semibold text-slate-300 tracking-wide"> <span className="text-xs font-semibold text-slate-200 tracking-wide">
Secure Role-Based Access Control (RBAC) Multi-Jurisdiction Regulatory Decision Framework
</span> </span>
</div> </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 Next-Generation Flight Disruption Orchestration
</h2> </h2>
<p className="text-sm lg:text-base text-slate-400 leading-relaxed"> <p className="text-sm xl:text-base text-slate-300 leading-relaxed font-normal">
Automate passenger segmentation, multi-jurisdiction regulatory compensation, goodwill Automate passenger cohort segmentation, statutory compensation (EU261, US DOT, APPR, UK261),
policies, and live manifest recovery simulations in real time. goodwill policies, and live manifest recovery simulation in real time.
</p> </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="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"> <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> <span className="text-xs text-slate-400 font-medium">Uptime Reliability</span>
</div> </div>
<div className="p-4 rounded-xl bg-white/[0.03] border border-white/5"> <div className="p-4 rounded-xl bg-white/[0.03] border border-white/5">
<span className="text-2xl font-bold text-teal-400 block">&lt; 150ms</span> <span className="text-2xl font-extrabold text-emerald-400 block">&lt; 150ms</span>
<span className="text-xs text-slate-400 font-medium">Policy Engine Evaluation</span> <span className="text-xs text-slate-400 font-medium">Evaluation Latency</span>
</div> </div>
</div> </div>
</div> </div>
{/* Bottom Trust Footer */} {/* 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>Aero Resolve Operating System v1.0</span>
<span>Aerospace High-Security Protocol</span> <span>Aerospace High-Security Protocol</span>
</div> </div>
</div> </div>
{/* Right / Login Form Column */} {/* 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 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 space-y-8"> <div className="w-full max-w-md">
{/* Header Mobile / Title */} <Sigin />
<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> </div>
</div> </div>
+2 -3
View File
@@ -1,12 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; 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 { CustomButton, CustomTabs, CustomBackButton, CustomStatus, Skeleton, CustomAlertBanner } from '../../../components/custom';
import SummaryTab from './SummaryTab'; import SummaryTab from './SummaryTab';
import CaseDetailsTab from './CaseDetailsTab'; import CaseDetailsTab from './CaseDetailsTab';
import RecoveryPlanTab from './RecoveryPlanTab'; import RecoveryPlanTab from './RecoveryPlanTab';
import AuditTrailTab from './AuditTrailTab'; import AuditTrailTab from './AuditTrailTab';
import { SparkleIcon } from 'lucide-react';
import { getRecoveryIncident, updateIncidentStatus, reRunPolicyEngine } from '../RecoveryIncidentsApi'; import { getRecoveryIncident, updateIncidentStatus, reRunPolicyEngine } from '../RecoveryIncidentsApi';
import type { RecoveryIncident } from '../RecoveryIncidentsTypes'; import type { RecoveryIncident } from '../RecoveryIncidentsTypes';
@@ -254,7 +253,7 @@ export default function RecoveryIncidentTabs() {
{/* Sidebar Content */} {/* Sidebar Content */}
<div className="p-6 select-none pointer-events-none"> <div className="p-6 select-none pointer-events-none">
<div className="flex items-center gap-2 mb-6"> <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> <h3 className="text-[13px] font-bold text-gray-400 tracking-wider">AI RECOMMENDATION</h3>
</div> </div>
+278 -177
View File
@@ -4,15 +4,15 @@ import {
CustomInput, CustomInput,
CustomButton, CustomButton,
CustomCheckBox, CustomCheckBox,
CustomTextArea,
CustomAlertBanner, CustomAlertBanner,
CustomSuccessModal,
Skeleton, Skeleton,
} from '../../../components/custom'; } from '../../../components/custom';
import { import {
ShieldCheckIcon, ShieldCheckIcon,
LockKeyIcon,
ArrowLeftIcon, ArrowLeftIcon,
FloppyDiskIcon, LockKeyIcon,
SquaresFourIcon,
} from '@phosphor-icons/react'; } from '@phosphor-icons/react';
interface AddRolesProps { interface AddRolesProps {
@@ -29,6 +29,8 @@ export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps)
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true); const [fetching, setFetching] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successTitle, setSuccessTitle] = useState('');
const isSystemRole = roleToEdit?.isSystem ?? false; const isSystemRole = roleToEdit?.isSystem ?? false;
@@ -103,8 +105,8 @@ export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps)
setSelectedPermissionIds(new Set()); setSelectedPermissionIds(new Set());
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e.preventDefault(); if (e) e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
setError('Role name is required.'); setError('Role name is required.');
return; return;
@@ -126,14 +128,16 @@ export default function AddRoles({ roleToEdit, onBack, onSaved }: AddRolesProps)
description: description.trim(), description: description.trim(),
permissionIds, permissionIds,
}); });
setSuccessTitle('Role Updated Successfully.');
} else { } else {
await RolesApi.createRole({ await RolesApi.createRole({
name: name.trim(), name: name.trim(),
description: description.trim(), description: description.trim(),
permissionIds, permissionIds,
}); });
setSuccessTitle('Role Created Successfully.');
} }
onSaved(); setShowSuccessModal(true);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.message || 'Failed to save role.'); setError(err?.response?.data?.message || 'Failed to save role.');
} finally { } 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) { if (fetching) {
return ( return (
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 p-6 space-y-4"> <div className="flex flex-col h-full bg-white min-h-screen">
<Skeleton width={260} height={32} /> {/* ─── Header Skeleton ────────────────────────────────────────────── */}
<Skeleton height={52} /> <div className="flex items-center justify-between py-4 bg-white border-b border-gray-200">
<Skeleton height={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> </div>
); );
} }
return ( return (
<div className="space-y-6 max-w-6xl mx-auto"> <div className="flex flex-col h-full bg-white min-h-screen">
{/* Header bar */} {/* ─── Header ────────────────────────────────────────────────────── */}
<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 justify-between py-4 bg-white border-b border-gray-200">
<div className="flex items-center gap-3"> <div className="flex items-start gap-4">
<CustomButton <button
variant="outlined" type="button"
size="sm"
leftIcon={<ArrowLeftIcon size={16} />}
onClick={onBack} onClick={onBack}
className="!rounded-[10px] !h-[38px]" className="mt-1 p-1 hover:bg-gray-100 rounded-full transition-colors text-gray-500"
> >
Back <ArrowLeftIcon size={20} />
</CustomButton> </button>
<div> <div className="flex flex-col">
<h2 className="text-lg font-bold text-[#0F172B] tracking-tight flex items-center gap-2"> <h1 className="text-xl font-bold text-[#0F172B]">
{roleToEdit ? `Edit Role: ${roleToEdit.name}` : 'Create Custom Role'} {roleToEdit ? (isSystemRole ? 'View System Role' : 'Edit Role') : 'Create Custom Role'}
{isSystemRole && ( </h1>
<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"> <span className="text-[13px] text-gray-500">Global Framework Registry</span>
<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>
</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> </div>
{/* Error Alert */} {/* ─── Body Content ──────────────────────────────────────────────── */}
{error && ( <div className="flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden py-6 pb-32">
<CustomAlertBanner <div className="w-full flex flex-col gap-6">
message={error} {/* Error Alert */}
type="error" {error && (
onClose={() => setError(null)} <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 {/* Role Information Card */}
label="Description" <div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
disabled={isSystemRole} <CardHeader icon={LockKeyIcon} title="Role Information" />
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="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="bg-white p-6 rounded-[16px] border border-gray-100 shadow-sm space-y-6"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <CustomInput
<div> label="Role Name"
<h3 className="text-sm font-bold text-[#0F172B] uppercase tracking-wider flex items-center gap-2"> required
<ShieldCheckIcon size={18} className="text-[#1B9869]" weight="bold" /> disabled={isSystemRole}
Granular Permissions Matrix value={name}
</h3> onChange={(e) => setName(e.target.value)}
<p className="text-xs text-[#6C766D] mt-0.5"> placeholder="e.g. Flight Disruption Lead"
Selected {selectedPermissionIds.size} permission(s) className="!h-11"
</p> />
</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> </div>
<div className="space-y-4"> {/* Granular Permissions Matrix Card */}
{Object.entries(groupedPermissions).map(([groupName, perms]) => { <div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
const groupIds = perms.map((p) => p.id); <div className="flex items-center justify-between mb-6">
const allGroupSelected = groupIds.every((id) => selectedPermissionIds.has(id)); <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 ( {!isSystemRole && (
<div <div className="flex items-center gap-3 mb-5">
key={groupName} <CustomButton
className="rounded-[12px] border border-gray-100 overflow-hidden transition-all bg-[#F3F6F5]/40 hover:bg-white" variant="outlined"
> size="sm"
{/* Module header */} onClick={selectAll}
<div className="px-5 py-3.5 bg-[#F3F6F5] border-b border-gray-100 flex items-center justify-between"> className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-emerald-50/50 font-semibold !h-9 text-xs"
<div className="flex items-center gap-3"> >
<CustomCheckBox Select All
disabled={isSystemRole} </CustomButton>
checked={allGroupSelected} <CustomButton
onChange={() => toggleGroup(groupName, perms)} variant="outlined"
/> size="sm"
<span className="text-[13px] font-bold text-[#0F172B]">{groupName}</span> onClick={deselectAll}
</div> className="!border-gray-300 !text-gray-600 hover:!bg-gray-50 font-semibold !h-9 text-xs"
<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 Deselect All
</span> </CustomButton>
</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>
<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> </div>
</div>
{/* Footer save buttons */} {/* ─── Sticky Footer ─────────────────────────────────────────────── */}
<div className="flex items-center justify-end gap-3 pt-2"> <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 <CustomButton
variant="outlined" variant="outlined"
size="md" className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
onClick={onBack} onClick={onBack}
className="!rounded-[10px] !h-[40px]" disabled={loading}
> >
Cancel Cancel
</CustomButton> </CustomButton>
{!isSystemRole && ( {!isSystemRole && (
<CustomButton <CustomButton
variant="primary" variant="primary"
size="md" className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm disabled:opacity-50"
leftIcon={<FloppyDiskIcon size={16} />} onClick={() => handleSubmit()}
disabled={loading || !name.trim() || selectedPermissionIds.size === 0}
loading={loading} 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> </CustomButton>
)} )}
</div> </div>
</form> </div>
{/* Success Modal */}
<CustomSuccessModal
isOpen={showSuccessModal}
onClose={() => {
setShowSuccessModal(false);
onSaved();
}}
title={successTitle}
label="ROLE NAME"
cohortName={name}
cohortDescription={description}
/>
</div> </div>
); );
} }
+239 -143
View File
@@ -1,12 +1,17 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo, useCallback } from 'react';
import { RolesApi, type RoleItem } from '../RolesApi'; import { RolesApi, type RoleItem } from '../RolesApi';
import { import {
CustomTable,
CustomInput, CustomInput,
CustomButton, CustomButton,
CustomAlertBanner, CustomAlertBanner,
CustomConfirmationModal, CustomConfirmationModal,
CustomActionMenu,
CustomActionItem,
CustomCheckBox,
Skeleton, Skeleton,
} from '../../../components/custom'; } from '../../../components/custom';
import type { Column } from '../../../components/custom/CustomTable';
import { import {
ShieldCheckIcon, ShieldCheckIcon,
PlusIcon, PlusIcon,
@@ -18,6 +23,26 @@ import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from '@phosphor-icons/react'; } from '@phosphor-icons/react';
import Can from '../../../components/common/Can'; 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 { interface RolesListProps {
onAddRole: () => void; onAddRole: () => void;
@@ -28,27 +53,35 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
const [roles, setRoles] = useState<RoleItem[]>([]); const [roles, setRoles] = useState<RoleItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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); const [successMsg, setSuccessMsg] = useState<string | null>(null);
useEffect(() => { // Pagination & Search
loadRoles(); 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); setLoading(true);
setError(null); setError(null);
try { try {
const data = await RolesApi.getRoles(); const data = await RolesApi.getRoles();
setRoles(data); setRoles(data);
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.message || 'Failed to load roles'); setError(err?.response?.data?.message || 'Failed to load roles list.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
useEffect(() => {
loadRoles();
}, [loadRoles]);
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteConfirmRole) return; if (!deleteConfirmRole) return;
@@ -65,6 +98,7 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
} }
}; };
// Filter & Pagination
const filteredRoles = useMemo(() => { const filteredRoles = useMemo(() => {
const q = search.toLowerCase().trim(); const q = search.toLowerCase().trim();
if (!q) return roles; if (!q) return roles;
@@ -76,6 +110,161 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
); );
}, [roles, search]); }, [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) { if (loading) {
return ( return (
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden"> <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} /> <Skeleton width={148} height={36} />
</div> </div>
</div> </div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> <div className="p-6 flex flex-col gap-4">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={180} className="!rounded-[16px]" /> <Skeleton key={i} height={52} />
))} ))}
</div> </div>
</div> </div>
@@ -111,137 +300,44 @@ export default function RolesList({ onAddRole, onEditRole }: RolesListProps) {
/> />
)} )}
{/* Top Action Bar */} {/* Standardized Table Section */}
<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"> <CustomTable<RoleItem>
<div className="w-full sm:w-[320px]"> columns={columns}
<CustomInput data={paginatedRoles}
placeholder="Search roles by name..." leftHeaderActions={
value={search} <div className="w-[320px]">
onChange={(e) => setSearch(e.target.value)} <CustomInput
leftIcon={<MagnifyingGlassIcon size={16} />} placeholder="Search roles by name, slug..."
className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]" value={search}
containerClassName="!gap-0" onChange={(e) => handleSearchChange(e.target.value)}
/> leftIcon={<MagnifyingGlassIcon size={16} />}
</div> className="!bg-[#F3F6F5] !rounded-[10px] !h-[40px] !border !border-[#E5E7EB]"
containerClassName="!gap-0"
<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>
))} }
</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 */} {/* Delete Confirmation Modal */}
<CustomConfirmationModal <CustomConfirmationModal
+3 -3
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { ShieldAlert, Shield } from 'lucide-react'; import { ShieldWarningIcon, ShieldCheckIcon } from '@phosphor-icons/react';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
@@ -25,7 +25,7 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
<div className="relative flex items-center justify-center"> <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="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"> <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>
</div> </div>
<p className="mt-4 text-xs font-semibold uppercase tracking-widest text-slate-500 animate-pulse"> <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 ( return (
<div className="min-h-[60vh] flex flex-col items-center justify-center p-6 text-center"> <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"> <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> </div>
<h2 className="text-xl font-bold text-slate-800 tracking-tight">Access Restricted</h2> <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"> <p className="text-sm text-slate-500 mt-1.5 max-w-md">
+120 -50
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useRef, useEffect } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { import {
SquaresFourIcon, SquaresFourIcon,
@@ -13,13 +13,14 @@ import {
CaretDoubleRightIcon, CaretDoubleRightIcon,
ShieldCheckIcon, ShieldCheckIcon,
LockKeyIcon, LockKeyIcon,
CaretUpIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
interface NavItem { interface NavItem {
label: string; label: string;
path: string; path: string;
icon: any; icon: React.ElementType;
permission?: string; permission?: string;
} }
@@ -43,8 +44,34 @@ 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 [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { user, logout, hasPermission } = useAuth(); 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) => { const visibleNavItems = NAV_ITEMS.filter((item) => {
if (!item.permission) return true; if (!item.permission) return true;
return hasPermission(item.permission); return hasPermission(item.permission);
@@ -104,7 +131,10 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
<CaretDoubleLeftIcon size={20} weight="bold" /> <CaretDoubleLeftIcon size={20} weight="bold" />
</button> </button>
<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"}`} className={`hidden lg:block text-slate-400 hover:text-slate-600 transition-colors ${isCollapsed ? "" : "ml-auto"}`}
> >
{isCollapsed ? ( {isCollapsed ? (
@@ -131,6 +161,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
to={item.path} to={item.path}
title={isCollapsed ? item.label : undefined} title={isCollapsed ? item.label : undefined}
onClick={() => { onClick={() => {
setIsMenuOpen(false);
if (window.innerWidth < 1024) onClose(); 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 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> </nav>
{/* Bottom Section Card */} {/* Bottom Section Card Container with Dropup Menu */}
<div <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 */} {/* Dropup Menu (Opens Upwards with transition) */}
{!isCollapsed && (
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/60 rounded-full blur-2xl pointer-events-none" />
)}
<div <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 <div className="flex flex-col gap-1">
onClick={logout} <button
title={isCollapsed ? "Sign Out" : undefined} type="button"
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`} onClick={() => setIsMenuOpen(false)}
> title="Help Center"
<SignOutIcon 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"
size={17} >
className="text-[#E02424] shrink-0" <QuestionIcon
weight="bold" size={17}
/> className="text-slate-700 shrink-0"
{!isCollapsed && ( weight="regular"
<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 && (
<span className="whitespace-nowrap">Help center</span> <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 <div
title={isCollapsed ? userDisplayName : undefined} className={`relative z-10 flex items-center ${
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"}`} 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"> <div className="flex items-center gap-2.5 min-w-0">
{initials} <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">
</div> {initials}
{!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> </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>
</div> </div>