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
+
+
+ ) : (
+ <>
+
+
+
+
+
+ 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
+
+
+
+
+ {/* Auth */}
+ {isAuthenticated && user ? (
+
+
+
+ {showUserMenu && (
+
+
+
{user.fullName}
+
{user.email}
+
+
+
+
+
+
+ )}
+
+ ) : (
+
+
+ Sign in
+
+
+ Register
+
+
+ )}
+
{/* Mobile Menu Button */}
)}
+
+ setShowChangePassword(false)}
+ />
);
}
diff --git a/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx b/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx
new file mode 100644
index 000000000..a20301e85
--- /dev/null
+++ b/apps/edr-passenger-web/portal/src/components/ChangePasswordModal.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { useState } from 'react';
+import { createPortal } from 'react-dom';
+import { X, CheckCircle } from 'lucide-react';
+import { iamAuthApi } from '@/lib/api/auth';
+
+interface ChangePasswordModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) {
+ const [currentPassword, setCurrentPassword] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+
+ if (!isOpen) return null;
+
+ const handleClose = () => {
+ setCurrentPassword('');
+ setNewPassword('');
+ setConfirmPassword('');
+ setError('');
+ setSuccess(false);
+ onClose();
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ 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 === currentPassword) {
+ setError('New password must be different from the current password.');
+ return;
+ }
+ setLoading(true);
+ try {
+ await iamAuthApi.changePassword({
+ oldPassword: currentPassword,
+ newPassword,
+ confirmPassword,
+ });
+ setSuccess(true);
+ setTimeout(() => handleClose(), 1500);
+ } catch (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.');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Portal to with a high z-index: the modal is mounted inside the
+ // sticky z-50 header, whose stacking context would otherwise let page
+ // content (e.g. the trip search widget) render on top of it.
+ return createPortal(
+
+
+
+
Change Password
+
+
+
+
+
,
+ 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 ? (
+
+ ) : (
+
+ )}
+
+ {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') {