feat: ( iam ) add forgot and change password to passenger backoffice

This commit is contained in:
Abubeker Yasin
2026-07-02 10:27:08 +03:00
parent 448e64da02
commit d6938df66c
7 changed files with 585 additions and 4 deletions

View File

@@ -5,9 +5,10 @@ import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store';
import {
Eye, EyeOff, Sun, Moon, ArrowRight, Loader2,
TicketCheck, Users, TrendingUp, ShieldCheck,
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)';
@@ -28,6 +29,13 @@ export default function LoginPage() {
const [emailFocused, setEmailFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const [view, setView] = useState<'login' | 'forgot'>('login');
const [forgotEmail, setForgotEmail] = 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();
@@ -53,6 +61,31 @@ export default function LoginPage() {
}
};
const handleForgotSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setForgotLoading(true);
setForgotError('');
try {
await iamAuthApi.forgotPassword(forgotEmail);
setForgotSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setForgotError(
msg === 'user_not_found'
? 'No account found with that email address.'
: 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 (
@@ -94,6 +127,8 @@ export default function LoginPage() {
{/* 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' }}>
@@ -171,6 +206,15 @@ export default function LoginPage() {
{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'); setForgotEmail(email); setError(''); }}
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
>
Forgot password?
</button>
</div>
</div>
{/* Submit */}
@@ -206,6 +250,112 @@ export default function LoginPage() {
</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 address and we&apos;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 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 ${
forgotFocused
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
: 'ring-1 ring-gray-200 dark:ring-gray-800'
}`}>
<input
type="email"
value={forgotEmail}
onChange={(e) => { setForgotEmail(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"
required
autoComplete="email"
/>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={forgotLoading || !forgotEmail}
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 || !forgotEmail
? '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>

View File

@@ -0,0 +1,228 @@
'use client';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get('email') || '';
const userId = searchParams.get('userId') || '';
const verificationCode = searchParams.get('verificationCode') || '';
const linkValid = Boolean(email && userId && verificationCode);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [newFocused, setNewFocused] = useState(false);
const [confirmFocused, setConfirmFocused] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (newPassword.length < 6) {
setError('Password must be at least 6 characters.');
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setLoading(true);
try {
await iamAuthApi.resetPassword({ userId, email, verificationCode, newPassword, confirmPassword });
setSuccess(true);
setTimeout(() => router.push('/login'), 2000);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 px-8 py-10">
<div className="w-full max-w-xs">
{/* Logo */}
<div className="flex items-center gap-2.5 mb-10">
<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>
{!linkValid ? (
<div className="animate-fade-up">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
Invalid reset link
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
This password reset link is invalid or incomplete. Request a new one from the sign-in page.
</p>
<Link
href="/login"
className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-[rgb(20,113,76)] hover:underline"
>
<ArrowLeft className="w-4 h-4" />
Back to sign in
</Link>
</div>
) : success ? (
<div className="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">
<CheckCircle2 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">
Password reset successfully. Redirecting to sign in
</p>
</div>
<Link
href="/login"
className="mt-6 inline-flex items-center gap-1.5 text-sm font-medium text-[rgb(20,113,76)] hover:underline"
>
Go to sign in
<ArrowRight className="w-4 h-4" />
</Link>
</div>
) : (
<>
{/* Heading */}
<div className="mb-8 animate-fade-up">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
Set a new password
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Choose a new password for <span className="font-medium text-gray-700 dark:text-gray-300">{email}</span>.
</p>
</div>
{/* Error */}
{error && (
<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 animate-fade-up">
<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>
)}
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-up">
{/* New password */}
<div>
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
New password
</label>
<div className={`relative rounded-xl transition-all duration-200 ${
newFocused
? '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={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
onFocus={() => setNewFocused(true)}
onBlur={() => setNewFocused(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
minLength={6}
autoComplete="new-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>
{/* Confirm password */}
<div>
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Confirm new password
</label>
<div className={`relative rounded-xl transition-all duration-200 ${
confirmFocused
? '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={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
onFocus={() => setConfirmFocused(true)}
onBlur={() => setConfirmFocused(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="••••••••••"
required
minLength={6}
autoComplete="new-password"
/>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={loading || !newPassword || !confirmPassword}
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 || !newPassword || !confirmPassword
? '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" />
Resetting
</>
) : (
<>
Reset password
<ArrowRight className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-0.5" />
</>
)}
</button>
</form>
<Link
href="/login"
className="mt-6 inline-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"
>
<ArrowLeft className="w-4 h-4" />
Back to sign in
</Link>
</>
)}
</div>
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
);
}