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
7 changes: 6 additions & 1 deletion backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ async function seedUser(email: string, password: string, role: UserRole, label:
});

if (existing) {
const updates: { role?: UserRole; emailVerified?: boolean } = {};
const updates: { role?: UserRole; emailVerified?: boolean; password?: string } = {};
if (existing.role !== role) updates.role = role;
if (!existing.emailVerified) updates.emailVerified = true;
// Keep the stored password in sync with the configured one, so the
// documented demo/admin credentials always work even if the account
// predates the seed or its password was changed in the app.
const passwordMatches = await bcrypt.compare(password, existing.password);
if (!passwordMatches) updates.password = await bcrypt.hash(password, 12);

Comment on lines 25 to 34
if (Object.keys(updates).length > 0) {
await prisma.user.update({
Expand Down
6 changes: 6 additions & 0 deletions backend/src/__tests__/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ describe('AuthService', () => {
where: { tokenHash: expect.any(String) },
include: { user: true },
});
// Completing a reset proves mailbox ownership, so the account must be
// marked verified or an unverified user stays locked out after a reset
expect(prismaMock.user.update).toHaveBeenCalledWith({
where: { id: mockUser.id },
data: { password: expect.any(String), emailVerified: true },
});
});

it('throws error for invalid token', async () => {
Expand Down
4 changes: 3 additions & 1 deletion backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,9 @@ class AuthService {
await prisma.$transaction([
prisma.user.update({
where: { id: resetToken.userId },
data: { password: hashedPassword },
// Completing a reset link proves mailbox ownership, so it also
// satisfies email verification.
data: { password: hashedPassword, emailVerified: true },
}),
prisma.passwordResetToken.update({
where: { id: resetToken.id },
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/__tests__/components/LoginPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import LoginPage from '../../components/auth/LoginPage';
// Mock the auth context
const mockLogin = jest.fn();
const mockRegister = jest.fn();
const mockVerifyEmail = jest.fn();
const mockResendVerificationCode = jest.fn();
const mockClearError = jest.fn();
const mockClearRegistrationSuccess = jest.fn();

jest.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({
login: mockLogin,
register: mockRegister,
verifyEmail: mockVerifyEmail,
resendVerificationCode: mockResendVerificationCode,
error: null,
clearError: mockClearError,
isLoading: false,
Expand Down Expand Up @@ -118,3 +122,80 @@ describe('LoginPage in register mode', () => {
});
});
});

describe('LoginPage in verify mode', () => {
// Reaches verify mode the same way a user does: an unverified login attempt
const enterVerifyMode = async (email = 'test@example.com') => {
mockLogin.mockRejectedValue(new Error('Email verification required'));
render(<LoginPage />);

await userEvent.type(screen.getByLabelText(/email address/i), email);
await userEvent.type(screen.getByLabelText(/^password/i), 'password123');
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));

await waitFor(() => {
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
});
};

it('renders an editable email field prefilled with the login email', async () => {
await enterVerifyMode('test@example.com');

const emailField = screen.getByLabelText(/email address/i);
expect(emailField).toHaveValue('test@example.com');
expect(emailField).toBeEnabled();
});

it('submits the visible email along with the code', async () => {
mockVerifyEmail.mockResolvedValue(undefined);
await enterVerifyMode('test@example.com');

await userEvent.type(screen.getByLabelText(/verification code/i), '123456');
fireEvent.click(screen.getByRole('button', { name: /verify email/i }));

await waitFor(() => {
expect(mockVerifyEmail).toHaveBeenCalledWith('test@example.com', '123456');
});
});

it('submits an edited email address', async () => {
mockVerifyEmail.mockResolvedValue(undefined);
await enterVerifyMode('test@example.com');

const emailField = screen.getByLabelText(/email address/i);
await userEvent.clear(emailField);
await userEvent.type(emailField, 'other@example.com');
await userEvent.type(screen.getByLabelText(/verification code/i), '654321');
fireEvent.click(screen.getByRole('button', { name: /verify email/i }));

await waitFor(() => {
expect(mockVerifyEmail).toHaveBeenCalledWith('other@example.com', '654321');
});
});

it('shows a validation error instead of a dead end when email is cleared', async () => {
await enterVerifyMode('test@example.com');

await userEvent.clear(screen.getByLabelText(/email address/i));
await userEvent.type(screen.getByLabelText(/verification code/i), '123456');
fireEvent.click(screen.getByRole('button', { name: /verify email/i }));

await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
expect(mockVerifyEmail).not.toHaveBeenCalled();
// The field stays on screen so the user can recover
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
});

it('resends the code to the visible email', async () => {
mockResendVerificationCode.mockResolvedValue(undefined);
await enterVerifyMode('test@example.com');

fireEvent.click(screen.getByText(/resend code/i));

await waitFor(() => {
expect(mockResendVerificationCode).toHaveBeenCalledWith('test@example.com');
});
});
});
19 changes: 13 additions & 6 deletions frontend/src/components/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const LoginPage: React.FC = () => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [pendingVerificationEmail, setPendingVerificationEmail] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
Expand All @@ -43,7 +42,6 @@ const LoginPage: React.FC = () => {
useEffect(() => {
if (registrationSuccess) {
setSuccessMessage('Account created. Enter the verification code sent to your email.');
setPendingVerificationEmail(email.trim());
setMode('verify');
setPassword('');
setConfirmPassword('');
Expand Down Expand Up @@ -95,12 +93,11 @@ const LoginPage: React.FC = () => {
} else if (mode === 'register') {
await register(email.trim(), password);
} else {
await verifyEmail((pendingVerificationEmail || email).trim(), verificationCode.trim());
await verifyEmail(email.trim(), verificationCode.trim());
}
} catch (err: any) {
if (mode === 'login' && err?.message === 'Email verification required') {
clearError();
setPendingVerificationEmail(email.trim());
setVerificationCode('');
setSuccessMessage('Enter the verification code sent to your email.');
setMode('verify');
Expand All @@ -121,7 +118,7 @@ const LoginPage: React.FC = () => {
clearError();
setLocalError(null);
setSuccessMessage(null);
const targetEmail = (pendingVerificationEmail || email).trim();
const targetEmail = email.trim();
if (!targetEmail) {
setLocalError('Email is required');
return;
Expand Down Expand Up @@ -200,7 +197,7 @@ const LoginPage: React.FC = () => {
? 'Sign in to start working on your models.'
: mode === 'register'
? 'Use your email and password to get started.'
: `Enter the 6-digit code sent to ${pendingVerificationEmail || email}.`}
: `Enter the 6-digit code sent to ${email.trim() || 'your email'}.`}
</Typography>
</Box>

Expand Down Expand Up @@ -230,6 +227,16 @@ const LoginPage: React.FC = () => {
<form onSubmit={handleSubmit}>
{mode === 'verify' ? (
<>
<TextField
fullWidth
label="Email Address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
margin="normal"
autoComplete="email"
disabled={isLoading}
/>
<TextField
fullWidth
label="Verification Code"
Expand Down