Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import SplashView from './views/SplashView';
import WelcomeView from './views/WelcomeView';
import LoginView from './views/LoginView';
import RegisterView from './views/RegisterView';
import ForgotPasswordView from './views/ForgotPasswordView';
import VerifyEmailView from './views/VerifyEmailView';
import ResetPasswordView from './views/ResetPasswordView';
import LandingPage from './views/LandingPage';
import HomeView from './views/HomeView';
import TodoListView from './views/TodoListView';
Expand Down Expand Up @@ -50,6 +53,9 @@ function App() {
<Route path="/welcome" element={<WelcomeView />} />
<Route path="/login" element={<LoginView />} />
<Route path="/register" element={<RegisterView />} />
<Route path="/forgot-password" element={<ForgotPasswordView />} />
<Route path="/verify-email" element={<VerifyEmailView />} />
<Route path="/reset-password" element={<ResetPasswordView />} />

{/* Dashboard (AppLayout shell — top nav, protected app routes).
RequireAuth bounces to /login when there is no restored session,
Expand Down
40 changes: 40 additions & 0 deletions client/src/components/OtpInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Six-digit one-time-code input, styled identically to the auth form fields
// (Field.tsx) so the verify-email and reset-password screens are
// indistinguishable from Login/Register. Digits only, six maximum; used by
// both OTP screens, never duplicated.
export default function OtpInput({
value,
onChange,
error,
disabled,
autoFocus,
}: {
value: string;
onChange: (value: string) => void;
error?: string;
disabled?: boolean;
autoFocus?: boolean;
}) {
return (
<label className="block">
<span className="mb-2 block font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-dim">
Verification code
</span>
<div className={`glass-input relative flex items-center rounded-[14px] ${error ? '!border-danger/70' : ''}`}>
<input
value={value}
disabled={disabled}
autoFocus={autoFocus}
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={6}
placeholder="000000"
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full rounded-[14px] bg-transparent px-4 py-3 text-center font-mono text-[22px] font-bold tracking-[0.5em] text-textdark outline-none placeholder:text-dim/40 disabled:opacity-70"
/>
</div>
{error && <span className="mt-1 block text-xs font-medium text-danger">{error}</span>}
</label>
);
}
23 changes: 22 additions & 1 deletion client/src/viewmodels/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ interface AuthContextValue {
}) => Promise<AppUser>;
signOut: () => Promise<void>;
forgotPassword: (email: string) => Promise<void>;
verifyEmail: (email: string, otp: string) => Promise<void>;
resendVerification: (email: string) => Promise<void>;
resetPassword: (email: string, otp: string, newPassword: string) => Promise<void>;
refreshProfile: () => Promise<AppUser>;
}

Expand Down Expand Up @@ -124,6 +127,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await apiClient.post('/auth/forgot-password', { email });
}, []);

const verifyEmail = useCallback(async (email: string, otp: string) => {
await apiClient.post('/auth/verify-email', { email, otp });
}, []);

const resendVerification = useCallback(async (email: string) => {
await apiClient.post('/auth/resend-verification', { email });
}, []);

const resetPassword = useCallback(
async (email: string, otp: string, newPassword: string) => {
await apiClient.post('/auth/reset-password', { email, otp, newPassword });
},
[],
);

const refreshProfile = useCallback(async () => {
const me = await apiClient.get<AppUser>('/me');
setUser(me);
Expand All @@ -139,9 +157,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register,
signOut,
forgotPassword,
verifyEmail,
resendVerification,
resetPassword,
refreshProfile,
}),
[user, isRestoring, login, register, signOut, forgotPassword, refreshProfile],
[user, isRestoring, login, register, signOut, forgotPassword, verifyEmail, resendVerification, resetPassword, refreshProfile],
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
Expand Down
49 changes: 49 additions & 0 deletions client/src/viewmodels/useForgotPasswordViewModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

import { useAuth, authErrorMessage } from './AuthContext';

/**
* Forgot-password screen behaviour: ask for the address, ask the server to
* issue a code, then hand off to the reset screen with the email carried in
* router state. The server replies identically whether or not the address has
* an account, so no path here distinguishes the two.
*/
export function useForgotPasswordViewModel() {
const navigate = useNavigate();
const { forgotPassword } = useAuth();
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);

async function submit(): Promise<boolean> {
if (!email.trim()) {
setMessage('Enter your email address.');
return false;
}
setIsLoading(true);
try {
await forgotPassword(email.trim());
navigate('/reset-password', { state: { email: email.trim() } });
return true;
} catch (error) {
setMessage(authErrorMessage(error));
return false;
} finally {
setIsLoading(false);
}
}

function clearMessage() {
if (message) setMessage(null);
}

return {
email,
setEmail,
isLoading,
message,
submit,
clearMessage,
};
}
19 changes: 1 addition & 18 deletions client/src/viewmodels/useLoginViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useAuth, authErrorMessage } from './AuthContext';
// the API, replacing the previous mock (any non-empty email/password).
export function useLoginViewModel() {
const navigate = useNavigate();
const { login, forgotPassword } = useAuth();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
Expand All @@ -30,22 +30,6 @@ export function useLoginViewModel() {
}
}

async function requestPasswordReset() {
if (!email.trim()) {
setMessage('Enter your email above to reset your password.');
return;
}
try {
await forgotPassword(email.trim());
// Phrased to match the server, which deliberately answers the same way
// whether or not the address has an account — see forgotPassword in
// the API. This is not a display bug; it is the point.
setMessage('If that email has an account, a reset link is on its way.');
} catch (error) {
setMessage(authErrorMessage(error));
}
}

function clearMessage() {
if (message) setMessage(null);
}
Expand All @@ -60,7 +44,6 @@ export function useLoginViewModel() {
isLoading,
message,
login: submit,
forgotPassword: requestPasswordReset,
clearMessage,
};
}
2 changes: 1 addition & 1 deletion client/src/viewmodels/useRegisterViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function useRegisterViewModel() {
setIsLoading(true);
try {
await register({ name: name.trim(), email: email.trim(), password });
navigate('/home', { replace: true });
navigate('/verify-email', { replace: true, state: { email: email.trim() } });
} catch (error) {
const fieldError = error instanceof ApiError ? error.errorFor('password') : undefined;
setMessage(fieldError ?? authErrorMessage(error));
Expand Down
22 changes: 22 additions & 0 deletions client/src/viewmodels/useResendCooldown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useCallback, useEffect, useState } from 'react';

const DEFAULT_COOLDOWN_SECONDS = 45;

/**
* Disable a "resend code" action for a short window after each send.
* Shared by the verify-email and reset-password screens so both resend
* buttons behave identically and the code is not re-issued on demand.
*/
export function useResendCooldown(seconds = DEFAULT_COOLDOWN_SECONDS) {
const [secondsLeft, setSecondsLeft] = useState(0);

useEffect(() => {
if (secondsLeft <= 0) return;
const timer = setInterval(() => setSecondsLeft((prev) => Math.max(0, prev - 1)), 1000);
return () => clearInterval(timer);
}, [secondsLeft]);

const start = useCallback(() => setSecondsLeft(seconds), [seconds]);

return { secondsLeft, canSend: secondsLeft === 0, start };
}
128 changes: 128 additions & 0 deletions client/src/viewmodels/useResetPasswordViewModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { ApiError } from '../api/errors';
import { useAuth, authErrorMessage } from './AuthContext';
import { useResendCooldown } from './useResendCooldown';

/**
* Two-step password reset on one screen.
*
* Step 1 captures the emailed code; step 2 the new password. Only the final
* submit talks to the server — there is no OTP-only endpoint — so a code
* failure bounces the user back to step 1 rather than leaving them stranded
* on the password form. Password policy is enforced server-side and surfaced
* through the same per-field error mechanism Register uses.
*/
export function useResetPasswordViewModel() {
const navigate = useNavigate();
const location = useLocation();
const { forgotPassword, resetPassword } = useAuth();
const { secondsLeft, canSend, start } = useResendCooldown();

const email = (location.state as { email?: string } | null)?.email ?? '';
const [step, setStep] = useState<'otp' | 'password'>('otp');
const [otp, setOtp] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isResending, setIsResending] = useState(false);
const [message, setMessage] = useState<string | null>(null);

// A code was just issued on the forgot-password screen, so the resend is
// gated by the cooldown from the moment this screen mounts.
useEffect(() => {
start();
}, [start]);

function next(): boolean {
if (otp.length !== 6) {
setMessage('Enter the 6-digit code sent to your email.');
return false;
}
setMessage(null);
setStep('password');
return true;
}

function backToOtp() {
setStep('otp');
setMessage(null);
}

async function submit(): Promise<boolean> {
if (!newPassword || !confirmPassword) {
setMessage('Please fill in all fields.');
return false;
}
if (newPassword !== confirmPassword) {
setMessage('Passwords do not match.');
return false;
}
setIsLoading(true);
try {
await resetPassword(email, otp, newPassword);
navigate('/login', { replace: true });
return true;
} catch (error) {
const isOtpFailure =
error instanceof ApiError && (error.isUnauthorized || error.isForbidden);
if (isOtpFailure) {
// Wrong or locked code — let them correct it or ask for a fresh one.
setStep('otp');
setOtp('');
setMessage(authErrorMessage(error));
} else {
// Password-policy or network failure — stay on the password step.
const fieldError = error instanceof ApiError ? error.errorFor('newPassword') : undefined;
setMessage(fieldError ?? authErrorMessage(error));
}
return false;
} finally {
setIsLoading(false);
}
}

async function resend(): Promise<boolean> {
setIsResending(true);
try {
// Re-issuing a reset code is exactly what /forgot-password does — it
// supersedes the old code and emails a fresh one.
await forgotPassword(email);
setStep('otp');
setOtp('');
start();
return true;
} catch (error) {
setMessage(authErrorMessage(error));
return false;
} finally {
setIsResending(false);
}
}

function clearMessage() {
if (message) setMessage(null);
}

return {
email,
step,
otp,
setOtp,
newPassword,
setNewPassword,
confirmPassword,
setConfirmPassword,
isLoading,
isResending,
message,
secondsLeft,
canSend,
next,
backToOtp,
submit,
resend,
clearMessage,
};
}
Loading
Loading