mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( iam ) add forgot and change password to passenger backoffice
This commit is contained in:
@@ -43,6 +43,13 @@ JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||
JWT_REFRESH_TOKEN_SECRET=<change-me-min-32-chars>
|
||||
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
|
||||
# @tria-plc IAM forgot-password flow — the reset link sent via SMS is
|
||||
# ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=..
|
||||
# Point at the backoffice web app. Without it the link starts with "undefined/".
|
||||
FE_BASE_URL=http://localhost:5184
|
||||
# OTP/reset-link TTL in minutes (IAM default: 30)
|
||||
INVITATION_EXPIRY_DATE=30
|
||||
|
||||
# SendGrid
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_FROM_EMAIL=noreply@edr-platform.com
|
||||
@@ -151,7 +158,10 @@ FAYDA_TOKEN_ENDPOINT=
|
||||
FAYDA_USERINFO_ENDPOINT=
|
||||
# Base64 of the RSA private JWK (JSON). Secret — never commit a real value.
|
||||
FAYDA_PRIVATE_KEY_BASE64=
|
||||
# OAuth redirect_uri passed to eSignet for MOBILE clients (the app calls /complete directly).
|
||||
FAYDA_REDIRECT_URI=
|
||||
# OAuth redirect_uri passed to eSignet for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||
FAYDA_WEB_REDIRECT_URI=
|
||||
# Optional (defaults shown)
|
||||
FAYDA_SCOPE=openid profile email
|
||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
@@ -160,6 +170,15 @@ FAYDA_SESSION_TTL_MINUTES=10
|
||||
|
||||
GITHUB_PACKAGE_TOKEN=<your-github-packages-token>
|
||||
|
||||
# --- Seeding -----------------------------------------------------------------
|
||||
# Set both to true on first run (or when resetting) to seed org, roles, and
|
||||
# default backoffice staff users. Safe to leave true — all operations are idempotent.
|
||||
# Login endpoint for backoffice users: POST /v1/auth/login
|
||||
SEED_EDR_PASSENGER_ORG=false
|
||||
SEED_PASSENGER_STAFF=false
|
||||
# Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset.
|
||||
DEFAULT_PASSWORD=Admin@1234
|
||||
|
||||
# --- Notification broker (RabbitMQ) -----------------------------------------------------------------
|
||||
# Set RABBITMQ_ENABLED=false to skip connection entirely (dev without a local broker).
|
||||
RABBITMQ_ENABLED=false
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) {
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: () => iamAuthApi.changePassword({ oldPassword, newPassword, confirmPassword }),
|
||||
onSuccess: () => {
|
||||
setSuccess(true);
|
||||
setTimeout(() => handleClose(), 1500);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err.response?.data?.message || '';
|
||||
if (err.response?.status === 401) {
|
||||
setError('Current password is incorrect.');
|
||||
} else if (msg === 'new_password_same_as_old') {
|
||||
setError('New password must be different from the current password.');
|
||||
} else if (msg === 'new_passwords_do_not_match') {
|
||||
setError('New passwords do not match.');
|
||||
} else {
|
||||
setError(msg || 'Failed to change password. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
if (newPassword === oldPassword) {
|
||||
setError('New password must be different from the current password.');
|
||||
return;
|
||||
}
|
||||
changePasswordMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Change Password">
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{success && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 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 changed successfully.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Current Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={oldPassword}
|
||||
onChange={(e) => { setOldPassword(e.target.value); setError(''); }}
|
||||
placeholder="Enter current password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">New Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
placeholder="Enter new password"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Confirm New Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
placeholder="Re-enter new password"
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
loading={changePasswordMutation.isPending}
|
||||
disabled={success || !oldPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
Change Password
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle } from 'lucide-react';
|
||||
import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle, KeyRound } from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import ChangePasswordModal from '@/components/layout/ChangePasswordModal';
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-6 shadow-sm">
|
||||
@@ -108,6 +110,16 @@ export default function Header() {
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowChangePassword(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
@@ -123,6 +135,11 @@ export default function Header() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
26
apps/edr-passenger-web/backoffice/src/lib/api/auth.ts
Normal file
26
apps/edr-passenger-web/backoffice/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
|
||||
export const iamAuthApi = {
|
||||
forgotPassword: (email: string) =>
|
||||
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
|
||||
|
||||
resetPassword: (data: {
|
||||
userId: string;
|
||||
email: string;
|
||||
verificationCode: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) => axios.patch(`${API_URL}/v1/auth/set-password`, data),
|
||||
|
||||
changePassword: (data: {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) =>
|
||||
axios.patch(`${API_URL}/v1/auth/change-password`, data, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
}),
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const PUBLIC_PATHS = ['/login'];
|
||||
const PUBLIC_PATHS = ['/login', '/reset-password'];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
Reference in New Issue
Block a user