diff --git a/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx b/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx new file mode 100644 index 000000000..8f36e4039 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/fayda-setup/page.tsx @@ -0,0 +1,5 @@ +import FaydaSetupWizard from '@/components/FaydaSetupWizard'; + +export default function FaydaSetupPage() { + return ; +} diff --git a/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx new file mode 100644 index 000000000..254b65eb8 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/forgot-password/page.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Train, MailCheck, ArrowLeft } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [sent, setSent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await iamAuthApi.forgotPassword(email); + setSent(true); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError( + msg === 'user_not_found' + ? 'No account found with that email address.' + : msg || 'Failed to send the reset link. Please try again.' + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Reset your password

+

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

+
+ +
+ {sent ? ( +
+
+ +

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

+
+ + + Back to sign in + +
+ ) : ( + <> +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setEmail(e.target.value); setError(''); }} + className="input-field" + placeholder="your@email.com" + autoComplete="email" + required + /> +
+ + +
+ +
+ + + Back to sign in + +
+ + )} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index 86ac4dcce..9453c39e2 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -6,7 +6,8 @@ import { z } from 'zod'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useState, Suspense } from 'react'; -import { Train } from 'lucide-react'; +import Link from 'next/link'; +import { Train, ShieldCheck } from 'lucide-react'; const loginSchema = z.object({ email: z.string().email('Invalid email address'), @@ -85,6 +86,14 @@ function LoginContent() { {errors.password && (

{errors.password.message}

)} +
+ + Forgot password? + +
-
+
+
+ Don't have an account? + + Create account + +
+ + + Already verified with Fayda? Set up your password + +
+ +
+ + +
+ + + Already verified with Fayda? Set up your password + +
+ +
+ Already have an account? + + Sign in + +
+
+
+ + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx b/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx new file mode 100644 index 000000000..da61c9da5 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/reset-password/page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +function ResetPasswordContent() { + 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 [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = 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.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Set a new password

+ {linkValid && !success && ( +

+ Choose a new password for {email}. +

+ )} +
+ +
+ {!linkValid ? ( +
+
+ This password reset link is invalid or incomplete. Request a new one from the sign-in page. +
+ + Request a new link + + + + Back to sign in + +
+ ) : success ? ( +
+
+ +

Password reset successfully. Redirecting to sign in…

+
+ + Go to sign in + + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ + + + + + Back to sign in + + + )} +
+
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/app/set-password/page.tsx b/apps/edr-passenger-web/portal/src/app/set-password/page.tsx new file mode 100644 index 000000000..2c270f727 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/set-password/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import FaydaSetupWizard from '@/components/FaydaSetupWizard'; + +// Landing page for the IAM's Fayda set-password SMS link: +// ${FE_BASE_URL}/set-password?email=..&userId=..&verificationCode=.. +// The wizard starts at step 2 with the code prefilled; the user enters their +// phone number (verify-and-login requires it) and a new password. +function SetPasswordContent() { + const searchParams = useSearchParams(); + const verificationCode = searchParams.get('verificationCode') || ''; + + return ; +} + +export default function SetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 200b07222..b1fc2ddf5 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -1,18 +1,24 @@ "use client"; -import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; +import { Menu, X, Moon, Sun, HelpCircle, KeyRound, LogOut, ChevronDown } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; +import { useAuthStore } from "@/lib/auth-store"; +import ChangePasswordModal from "@/components/ChangePasswordModal"; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); const [isDark, setIsDark] = useState(false); + const [showUserMenu, setShowUserMenu] = useState(false); + const [showChangePassword, setShowChangePassword] = useState(false); + const { user, isAuthenticated, initialize, logout } = useAuthStore(); useEffect(() => { const isDarkMode = document.documentElement.classList.contains("dark"); setIsDark(isDarkMode); - }, []); + initialize(); + }, [initialize]); const toggleTheme = () => { const html = document.documentElement; @@ -84,6 +90,67 @@ export default function AppHeader() { )} + {/* Auth */} + {isAuthenticated && user ? ( +
+ + + {showUserMenu && ( +
+
+

{user.fullName}

+

{user.email}

+
+
+ + +
+
+ )} +
+ ) : ( +
+ + Sign in + + + Register + +
+ )} + {/* Mobile Menu Button */} + +
+ {success && ( +
+ +

Password changed successfully.

+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + { setCurrentPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="current-password" + required + /> +
+
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="new-password" + minLength={6} + required + /> +
+
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + autoComplete="new-password" + minLength={6} + required + /> +
+
+ + +
+
+ + , + document.body + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx new file mode 100644 index 000000000..92f3843fc --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/FaydaSetupWizard.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; + +interface FaydaSetupWizardProps { + // Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...) + initialOtp?: string; +} + +type Outcome = 'success' | 'hasPassword' | null; + +export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps) { + const router = useRouter(); + const [step, setStep] = useState<1 | 2>(initialOtp ? 2 : 1); + const [phone, setPhone] = useState(''); + const [otp, setOtp] = useState(initialOtp || ''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [outcome, setOutcome] = useState(null); + + const handleRequestCode = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await iamAuthApi.faydaRequestPasswordSetup(phone); + setStep(2); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to send the code. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleSetPassword = 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 { + const res = await iamAuthApi.faydaVerifyAndLogin({ phoneNumber: phone, otp }); + const data = (res.data as any)?.data ?? res.data; + if (!data.requiresPassword) { + setOutcome('hasPassword'); + return; + } + await iamAuthApi.setFaydaPassword( + { userId: data.iamUserId, newPassword, confirmPassword }, + data.token, + ); + setOutcome('success'); + setTimeout(() => router.push('/login'), 2500); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Verification failed. The code may be wrong or expired.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

+ + Fayda account setup +

+

+ Already verified with Fayda? Set a password to access your account online. +

+
+ +
+ {outcome === 'success' ? ( +
+
+ +

+ Your password has been set and your account is now active. Redirecting to sign in… +

+
+ + Go to sign in + + +
+ ) : outcome === 'hasPassword' ? ( +
+
+ +

+ This account already has a password. Sign in with your email or phone number, + or use forgot password if you can't remember it. +

+
+ + Sign in + + + Forgot password? + +
+ ) : step === 1 ? ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + { setPhone(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678" + autoComplete="tel" + required + /> +

+ The phone number you used during Fayda verification. +

+
+ + +
+ ) : ( +
+
+ +

+ If this phone number is Fayda-verified, an SMS with a verification code has been sent. + Enter it below with your new password. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + { setPhone(e.target.value); setError(''); }} + className="input-field" + placeholder="+251912345678" + autoComplete="tel" + required + /> +
+ +
+ + { setOtp(e.target.value); setError(''); }} + className="input-field" + placeholder="6-character code from SMS" + maxLength={6} + required + /> +
+ +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={6} + required + /> +
+ + + + +
+ )} + + {outcome === null && ( +
+ + + Back to sign in + +
+ )} +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts new file mode 100644 index 000000000..aa5272173 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + +// IAM (/v1/auth/*) and Fayda (/auth/fayda/*) endpoints use raw axios instead of +// apiClient: apiClient's response interceptor clears the token and redirects to +// /login on any 401 for non-public URLs — but the IAM returns 401 when the +// current password is wrong on change-password, and OTP failures must surface +// as inline errors, not a logout. +export const iamAuthApi = { + forgotPassword: (email: string) => + axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + + // Completes the forgot-password flow using the link sent via SMS: + // ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. + 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')}` }, + }), + + faydaRequestPasswordSetup: (phoneNumber: string) => + axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }), + + faydaVerifyAndLogin: (data: { phoneNumber: string; otp: string }) => + axios.post<{ + success: boolean; + data: { token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }; + }>(`${API_URL}/auth/fayda/verify-and-login`, data), + + // Bearer token comes from faydaVerifyAndLogin's response, not localStorage — + // the user is not logged into the portal at this point. + setFaydaPassword: ( + data: { userId: string; newPassword: string; confirmPassword: string }, + token: string, + ) => + axios.patch(`${API_URL}/v1/auth/set-fayda-password`, data, { + headers: { Authorization: `Bearer ${token}` }, + }), +}; diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 72a89ce87..2157cfc0a 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -40,10 +40,11 @@ interface AuthState { } interface RegisterData { + fullName: string; email: string; phone: string; - fullName: string; password: string; + confirmPassword: string; } export const useAuthStore = create((set, get) => ({ @@ -118,7 +119,16 @@ export const useAuthStore = create((set, get) => ({ }, register: async (data: RegisterData) => { - const response: any = await apiClient.post('/auth/register', data); + // Shape required by the passenger-api RegisterDto; username = email by convention. + const payload = { + email: data.email, + username: data.email, + phoneNumber: data.phone, + name: { en: data.fullName, am: data.fullName }, + password: data.password, + confirmPassword: data.confirmPassword, + }; + const response: any = await apiClient.post('/auth/register', payload); const { token, user } = response.data || response; if (typeof window !== 'undefined') {