mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
447 lines
20 KiB
TypeScript
447 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useAuthStore } from '@/lib/auth-store';
|
|
import { useTheme } from '@/lib/theme-store';
|
|
import {
|
|
Eye, EyeOff, Sun, Moon, ArrowRight, ArrowLeft, Loader2,
|
|
TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck,
|
|
} from 'lucide-react';
|
|
import { iamAuthApi } from '@/lib/api/auth';
|
|
|
|
const EDR_GREEN = 'rgb(20, 113, 76)';
|
|
|
|
const features = [
|
|
{ icon: TicketCheck, label: 'Booking Management', desc: 'Full lifecycle booking operations' },
|
|
{ icon: Users, label: 'Passenger Services', desc: 'Profiles, loyalty & wallet' },
|
|
{ icon: TrendingUp, label: 'Revenue Analytics', desc: 'Real-time reports & insights' },
|
|
{ icon: ShieldCheck, label: 'Fraud Detection', desc: 'Automated risk monitoring' },
|
|
];
|
|
|
|
export default function LoginPage() {
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
const [emailFocused, setEmailFocused] = useState(false);
|
|
const [passwordFocused, setPasswordFocused] = useState(false);
|
|
|
|
const [view, setView] = useState<'login' | 'forgot'>('login');
|
|
const [forgotIdentifier, setForgotIdentifier] = useState('');
|
|
const [forgotLoading, setForgotLoading] = useState(false);
|
|
const [forgotError, setForgotError] = useState('');
|
|
const [forgotSent, setForgotSent] = useState(false);
|
|
const [forgotFocused, setForgotFocused] = useState(false);
|
|
|
|
const router = useRouter();
|
|
const { login } = useAuthStore();
|
|
const { isDark, toggleTheme } = useTheme();
|
|
|
|
useEffect(() => { setIsMounted(true); }, []);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
await login(email, password);
|
|
router.push('/dashboard');
|
|
} catch (err: any) {
|
|
const msg = err.message || err.response?.data?.message || '';
|
|
if (msg === 'ACCESS_DENIED') {
|
|
setError('This account does not have back-office access. Contact your administrator.');
|
|
} else {
|
|
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleForgotSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setForgotLoading(true);
|
|
setForgotError('');
|
|
try {
|
|
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
|
|
setForgotSent(true);
|
|
} catch (err: any) {
|
|
const msg = err.response?.data?.message || err.message || '';
|
|
setForgotError(
|
|
msg === 'user_not_found'
|
|
? 'No account found with that email or phone number.'
|
|
: msg || 'Failed to send the reset link. Please try again.'
|
|
);
|
|
} finally {
|
|
setForgotLoading(false);
|
|
}
|
|
};
|
|
|
|
const backToLogin = () => {
|
|
setView('login');
|
|
setForgotError('');
|
|
setForgotSent(false);
|
|
};
|
|
|
|
if (!isMounted) return null;
|
|
|
|
return (
|
|
<div className="flex min-h-screen bg-white dark:bg-gray-950">
|
|
|
|
{/* ── LEFT PANEL — form ── */}
|
|
<div className="flex-1 lg:flex-none lg:w-[42%] xl:w-[38%] flex flex-col min-h-screen bg-gray-50 dark:bg-gray-950 relative">
|
|
|
|
{/* Top bar */}
|
|
<div className="flex items-center justify-between px-8 py-4 lg:px-10">
|
|
{/* Logo — always visible on the form panel */}
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-8 h-8 rounded-lg bg-[rgb(20,113,76)] flex items-center justify-center shadow-md shadow-[rgb(20,113,76)]/30">
|
|
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8 2 5 5 5 8v8l2 2h10l2-2V8c0-3-3-6-7-6z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 17v2M16 17v2M5 12h14" />
|
|
<circle cx="9" cy="9" r="1" fill="currentColor" />
|
|
<circle cx="15" cy="9" r="1" fill="currentColor" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-bold text-gray-900 dark:text-white tracking-wide leading-none">ETHIO-DJIBOUTI</div>
|
|
<div className="text-[12px] text-gray-400 dark:text-gray-500 tracking-widest uppercase leading-none mt-0.5">Railway</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="p-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400"
|
|
aria-label="Toggle theme"
|
|
>
|
|
{isDark
|
|
? <Sun className="w-4 h-4 text-amber-400" />
|
|
: <Moon className="w-4 h-4" />
|
|
}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Form area */}
|
|
<div className="flex-1 flex items-center justify-center px-8 py-10 lg:px-10 xl:px-14">
|
|
<div className="w-full max-w-xs">
|
|
{view === 'login' ? (
|
|
<>
|
|
|
|
{/* Heading */}
|
|
<div className="mb-8 animate-fade-up" style={{ animationDelay: '0ms' }}>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
|
|
Sign in to continue
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Enter your credentials to access the back-office.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
|
|
<div className="mb-5 flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 px-4 py-3">
|
|
<div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
|
|
<span className="text-white text-[10px] font-bold">!</span>
|
|
</div>
|
|
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
|
</div></div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
|
|
|
{/* Email field */}
|
|
<div>
|
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
|
Email address
|
|
</label>
|
|
<div className={`relative rounded-xl transition-all duration-200 ${
|
|
emailFocused
|
|
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
|
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
|
}`}>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
|
onFocus={() => setEmailFocused(true)}
|
|
onBlur={() => setEmailFocused(false)}
|
|
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
|
placeholder="name@edr.com"
|
|
required
|
|
autoComplete="email"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Password field */}
|
|
<div>
|
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
|
Password
|
|
</label>
|
|
<div className={`relative rounded-xl transition-all duration-200 ${
|
|
passwordFocused
|
|
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
|
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
|
}`}>
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
|
onFocus={() => setPasswordFocused(true)}
|
|
onBlur={() => setPasswordFocused(false)}
|
|
className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
|
placeholder="••••••••••"
|
|
required
|
|
autoComplete="current-password"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
|
|
aria-label="Toggle password visibility"
|
|
>
|
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
<div className="flex justify-end mt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setView('forgot'); setForgotIdentifier(email); setError(''); }}
|
|
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
|
|
>
|
|
Forgot password?
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !email || !password}
|
|
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
style={{ background: loading || !email || !password
|
|
? 'rgb(20,113,76)'
|
|
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Signing in…
|
|
</>
|
|
) : (
|
|
<>
|
|
Sign in
|
|
<ArrowRight className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Divider */}
|
|
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800/60 animate-fade-up" style={{ animationDelay: '160ms' }}>
|
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30">
|
|
<ShieldCheck className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
|
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
|
|
Access is restricted to authorised EDR staff only. All sessions are logged and audited.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
|
|
{/* Heading */}
|
|
<div className="mb-8 animate-fade-up" style={{ animationDelay: '0ms' }}>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
|
|
Reset your password
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Enter your email or phone number and we'll send a reset link to the phone number on your account.
|
|
</p>
|
|
</div>
|
|
|
|
{forgotSent ? (
|
|
<div className="space-y-5 animate-fade-up">
|
|
<div className="flex items-start gap-3 rounded-xl bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-900/60 px-4 py-3">
|
|
<MailCheck className="w-4 h-4 mt-0.5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
|
<p className="text-sm text-emerald-700 dark:text-emerald-300">
|
|
A password reset link has been sent via SMS. Open it to set a new password — the link expires in 30 minutes.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={backToLogin}
|
|
className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200"
|
|
style={{ background: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)` }}
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back to sign in
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Error */}
|
|
{forgotError && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
|
|
<div className="mb-5 flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 px-4 py-3">
|
|
<div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
|
|
<span className="text-white text-[10px] font-bold">!</span>
|
|
</div>
|
|
<p className="text-sm text-red-700 dark:text-red-300">{forgotError}</p>
|
|
</div></div>
|
|
)}
|
|
|
|
<form onSubmit={handleForgotSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
|
{/* Email or phone field */}
|
|
<div>
|
|
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
|
Email or phone number
|
|
</label>
|
|
<div className={`relative rounded-xl transition-all duration-200 ${
|
|
forgotFocused
|
|
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
|
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
|
}`}>
|
|
<input
|
|
type="text"
|
|
value={forgotIdentifier}
|
|
onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }}
|
|
onFocus={() => setForgotFocused(true)}
|
|
onBlur={() => setForgotFocused(false)}
|
|
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
|
placeholder="name@edr.com or +251..."
|
|
required
|
|
autoComplete="username"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<button
|
|
type="submit"
|
|
disabled={forgotLoading || !forgotIdentifier.trim()}
|
|
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
style={{ background: forgotLoading || !forgotIdentifier.trim()
|
|
? 'rgb(20,113,76)'
|
|
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
|
}}
|
|
>
|
|
{forgotLoading ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Sending…
|
|
</>
|
|
) : (
|
|
<>
|
|
Send reset link
|
|
<ArrowRight className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={backToLogin}
|
|
className="mt-6 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-[rgb(20,113,76)] transition-colors animate-fade-up"
|
|
style={{ animationDelay: '120ms' }}
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back to sign in
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom bar */}
|
|
<div className="px-8 py-4 lg:px-10 flex items-center justify-between">
|
|
<span className="text-xs text-gray-400 dark:text-gray-600">
|
|
Back-office · v1.0
|
|
</span>
|
|
<span className="text-xs text-gray-400 dark:text-gray-600">
|
|
Need help? <a href="mailto:edr_@edrsc.com" className="text-[rgb(20,113,76)] hover:underline">edr_@edrsc.com</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── RIGHT PANEL — photo ── */}
|
|
<div className="hidden lg:flex flex-1 relative flex-col overflow-hidden">
|
|
{/* Layer 1 — base photo, desaturated */}
|
|
<div
|
|
className="absolute inset-0 bg-cover bg-center"
|
|
style={{
|
|
backgroundImage: "url('/banner.jpg')",
|
|
filter: isDark
|
|
? 'saturate(0.1) brightness(1)'
|
|
: 'saturate(0.15) brightness(1)',
|
|
}}
|
|
/>
|
|
|
|
{/* Layer 2 — brand green color wash */}
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background: 'linear-gradient(145deg, rgb(5,46,30) 0%, rgb(20,113,76) 55%, rgb(4,120,67) 100%)',
|
|
mixBlendMode: 'multiply',
|
|
opacity: isDark ? 0.8 : 0.4,
|
|
}}
|
|
/>
|
|
|
|
{/* Content */}
|
|
<div className="relative z-10 flex flex-col h-full p-10 xl:p-14">
|
|
{/* Badge */}
|
|
<div className="flex justify-start">
|
|
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm border border-white/20 rounded-full px-3 py-1">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|
<span className="text-white/80 text-xs font-medium tracking-wide">Back-office Portal v1.0</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hero text */}
|
|
<div className="mt-auto mb-auto">
|
|
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight mb-4">
|
|
Passenger<br />
|
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">
|
|
Management
|
|
</span>
|
|
<br />System
|
|
</h1>
|
|
<p className="text-white/60 text-base leading-relaxed max-w-sm">
|
|
Unified platform for booking operations, passenger services, revenue analytics, and real-time train management.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Feature grid */}
|
|
<div className="mt-auto grid grid-cols-2 gap-3">
|
|
{features.map(({ icon: Icon, label, desc }) => (
|
|
<div
|
|
key={label}
|
|
className="flex items-start gap-3 bg-white/5 hover:bg-white/10 backdrop-blur-sm border border-white/10 rounded-xl p-3.5 transition-colors duration-200"
|
|
>
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-[rgb(20,113,76)]/40 flex items-center justify-center">
|
|
<Icon className="w-4 h-4 text-emerald-300" />
|
|
</div>
|
|
<div>
|
|
<div className="text-white text-xs font-semibold">{label}</div>
|
|
<div className="text-white/40 text-xs mt-0.5">{desc}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Bottom rule */}
|
|
<div className="mt-8 pt-6 border-t border-white/10">
|
|
<span className="text-white/30 text-xs block">© 2026 Ethio-Djibouti Railway S.C. — Secure · Encrypted · Monitored</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|