diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 328023c28..aaed09659 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -43,6 +43,13 @@ JWT_ACCESS_TOKEN_EXPIRES=1h JWT_REFRESH_TOKEN_SECRET= 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= +# --- 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 diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index b2c90d275..f104db3da 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -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 */}
+ {view === 'login' ? ( + <> {/* Heading */}
@@ -171,6 +206,15 @@ export default function LoginPage() { {showPassword ? : }
+
+ +
{/* Submit */} @@ -206,6 +250,112 @@ export default function LoginPage() {

+ + ) : ( + <> + + {/* Heading */} +
+

+ Reset your password +

+

+ Enter your email address and we'll send a reset link to the phone number on your account. +

+
+ + {forgotSent ? ( +
+
+ +

+ A password reset link has been sent via SMS. Open it to set a new password — the link expires in 30 minutes. +

+
+ +
+ ) : ( + <> + {/* Error */} + {forgotError && (
+
+
+ ! +
+

{forgotError}

+
+ )} + +
+ {/* Email field */} +
+ +
+ { 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" + /> +
+
+ + {/* Submit */} + +
+ + + + )} + + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/reset-password/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reset-password/page.tsx new file mode 100644 index 000000000..62e81322c --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reset-password/page.tsx @@ -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 ( +
+
+ + {/* Logo */} +
+
+ + + + + + +
+
+
ETHIO-DJIBOUTI
+
Railway
+
+
+ + {!linkValid ? ( +
+

+ Invalid reset link +

+

+ This password reset link is invalid or incomplete. Request a new one from the sign-in page. +

+ + + Back to sign in + +
+ ) : success ? ( +
+
+ +

+ Password reset successfully. Redirecting to sign in… +

+
+ + Go to sign in + + +
+ ) : ( + <> + {/* Heading */} +
+

+ Set a new password +

+

+ Choose a new password for {email}. +

+
+ + {/* Error */} + {error && ( +
+
+ ! +
+

{error}

+
+ )} + +
+ {/* New password */} +
+ +
+ { 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" + /> + +
+
+ + {/* Confirm password */} +
+ +
+ { 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" + /> +
+
+ + {/* Submit */} + +
+ + + + Back to sign in + + + )} +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/ChangePasswordModal.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/ChangePasswordModal.tsx new file mode 100644 index 000000000..5641643fd --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/layout/ChangePasswordModal.tsx @@ -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 ( + +
{ e.preventDefault(); handleSubmit(); }} + className="space-y-4" + > + {success && ( +
+ +

Password changed successfully.

+
+ )} + + {error && ( +
+

{error}

+
+ )} + +
+ + { setOldPassword(e.target.value); setError(''); }} + placeholder="Enter current password" + autoComplete="current-password" + required + /> +
+
+ + { setNewPassword(e.target.value); setError(''); }} + placeholder="Enter new password" + autoComplete="new-password" + minLength={6} + required + /> +
+
+ + { setConfirmPassword(e.target.value); setError(''); }} + placeholder="Re-enter new password" + autoComplete="new-password" + minLength={6} + required + /> +
+ +
+ + Cancel + + + Change Password + +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx index 0e87eec43..f4547c69e 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Header.tsx @@ -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 (
@@ -108,6 +110,16 @@ export default function Header() { > Settings +
); } diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/auth.ts b/apps/edr-passenger-web/backoffice/src/lib/api/auth.ts new file mode 100644 index 000000000..1fe1eccbb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/api/auth.ts @@ -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')}` }, + }), +}; diff --git a/apps/edr-passenger-web/backoffice/src/middleware.ts b/apps/edr-passenger-web/backoffice/src/middleware.ts index 0b437d112..7e8a1545d 100644 --- a/apps/edr-passenger-web/backoffice/src/middleware.ts +++ b/apps/edr-passenger-web/backoffice/src/middleware.ts @@ -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;