From 904f3a8ec818fe91a8dfe7dde2bc2a1d46794b94 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:22:26 +0200 Subject: [PATCH] feat(auth): add configurable email verification enforcement Auth:RequireEmailVerification (default off) gates whether local register requires confirming email before login. OAuth new-user paths are untouched -- Google/Microsoft already assert a verified email. Adds verify-email and resend-verification-email endpoints, mirroring the existing reset-password enumeration-avoidance and rate-limiting patterns, plus a login-embedded resend affordance and a verify-email landing page on the frontend. Co-Authored-By: Claude Sonnet 5 --- .../AuthAndSystemControllerTests.cs | 203 ++++++++++++++++++ JobTrackerApi/Controllers/AuthController.cs | 103 ++++++++- JobTrackerApi/appsettings.Development.json | 1 + job-tracker-ui/src/App.tsx | 2 + job-tracker-ui/src/i18n/translations.ts | 18 ++ job-tracker-ui/src/login-page.test.tsx | 29 +++ job-tracker-ui/src/verify-email-page.test.tsx | 58 +++++ job-tracker-ui/src/views/LoginPage.tsx | 42 +++- job-tracker-ui/src/views/VerifyEmailPage.tsx | 75 +++++++ 9 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 job-tracker-ui/src/verify-email-page.test.tsx create mode 100644 job-tracker-ui/src/views/VerifyEmailPage.tsx diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 5b17fd2..45b767d 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -163,6 +163,209 @@ public sealed class AuthAndSystemControllerTests } } + [Fact] + public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Auth:AllowRegistration"] = "true", + ["Auth:RequireEmailVerification"] = "true", + }) + .Build(); + + var userManager = CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null); + ApplicationUser? created = null; + userManager + .Setup(x => x.CreateAsync(It.IsAny(), "password123")) + .Callback((u, _) => created = u) + .ReturnsAsync(IdentityResult.Success); + userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny())).ReturnsAsync("confirm-token"); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + var emailSender = new Mock(); + + var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None); + + Assert.IsType(result); + Assert.NotNull(created); + Assert.False(created!.EmailConfirmed); + emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny(), It.Is(b => b.Contains("verify-email")), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Register_is_unchanged_when_flag_off() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }) + .Build(); + + var userManager = CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null); + ApplicationUser? created = null; + userManager + .Setup(x => x.CreateAsync(It.IsAny(), "password123")) + .Callback((u, _) => created = u) + .ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + var emailSender = new Mock(); + + var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None); + + Assert.IsType(result); + Assert.NotNull(created); + Assert.True(created!.EmailConfirmed); + emailSender.Verify(x => x.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Login_rejects_unconfirmed_local_account_when_flag_on() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Auth:RequireEmailVerification"] = "true" }) + .Build(); + + var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); + userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null); + userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false); + userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); + userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); + + var controller = new AuthController(config, userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); + + var obj = Assert.IsType(result); + Assert.Equal(StatusCodes.Status403Forbidden, obj.StatusCode); + } + + [Fact] + public async Task Login_allows_unconfirmed_local_account_when_flag_off() + { + var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); + userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null); + userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false); + userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); + userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); + + var ok = Assert.IsType(result); + var session = Assert.IsType(ok.Value); + Assert.True(session.Authenticated); + } + + [Fact] + public async Task VerifyEmail_confirms_account_on_valid_token() + { + var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); + userManager.Setup(x => x.ConfirmEmailAsync(user, "good-token")).ReturnsAsync(IdentityResult.Success); + + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); + + var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token")); + + Assert.IsType(result); + } + + [Fact] + public async Task ResendVerificationEmail_returns_identical_response_for_real_and_fake_accounts() + { + var user = new ApplicationUser { Id = "user-1", Email = "real@example.com", UserName = "real@example.com", EmailConfirmed = false }; + var userManager = CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("real@example.com")).ReturnsAsync(user); + userManager.Setup(x => x.FindByEmailAsync("fake@example.com")).ReturnsAsync((ApplicationUser?)null); + userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true); + userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)).ReturnsAsync("confirm-token"); + + var emailSender = new Mock(); + + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var realResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("real@example.com"), CancellationToken.None); + var fakeResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("fake@example.com"), CancellationToken.None); + + Assert.IsType(realResult); + Assert.IsType(fakeResult); + emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Exchange_google_token_new_user_stays_EmailConfirmed_true_even_when_verification_flag_is_on() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Auth:AllowRegistration"] = "true", + ["Auth:RequireEmailVerification"] = "true", + }) + .Build(); + + var userManager = CreateUserManager(); + userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); + userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null); + ApplicationUser? created = null; + userManager + .Setup(x => x.CreateAsync(It.IsAny())) + .Callback(u => created = u) + .ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + var googleValidator = new Mock(); + googleValidator + .Setup(x => x.ValidateAsync("google-token", It.IsAny())) + .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "new.hire@example.com", true, "New", "Hire", "New Hire")); + + var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); + + Assert.IsType(result); + Assert.NotNull(created); + Assert.True(created!.EmailConfirmed); + } + private static JobTrackerContext BuildDb(string dbName, string? currentUserId) { var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index af62956..83937f9 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -46,6 +46,7 @@ public sealed class AuthController : ControllerBase var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()); var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim()); var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); + var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false); return Ok(new { @@ -54,6 +55,7 @@ public sealed class AuthController : ControllerBase microsoftEnabled, localEnabled = true, allowRegistration, + requireEmailVerification, }); } @@ -113,6 +115,14 @@ public sealed class AuthController : ControllerBase await _users.ResetAccessFailedCountAsync(user); + // Same enumeration-avoidance discipline as the password-check branch above: this only + // runs once the password is already confirmed correct, so it can never be used to probe + // whether an email is registered. + if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed) + { + return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" }); + } + return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken); } @@ -133,13 +143,28 @@ public sealed class AuthController : ControllerBase var existing = await _users.FindByEmailAsync(email); if (existing is not null) return BadRequest("User already exists."); - var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true }; + var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false); + var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = !requireEmailVerification }; var res = await _users.CreateAsync(user, password); if (!res.Succeeded) { return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); } + if (requireEmailVerification) + { + try + { + await SendVerificationEmailAsync(user, cancellationToken); + } + catch (Exception ex) + { + // ponytail: don't fail registration over a flaky email send -- the account is + // created either way, the user can request a fresh link via resend-verification-email. + _logger.LogError(ex, "Failed to send verification email to {Email}", user.Email); + } + } + return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken); } @@ -662,6 +687,82 @@ public sealed class AuthController : ControllerBase return NoContent(); } + public sealed record VerifyEmailRequest(string UserId, string Token); + + [HttpPost("verify-email")] + [AllowAnonymous] + [EnableRateLimiting("auth-email")] + public async Task VerifyEmail([FromBody] VerifyEmailRequest request) + { + var userId = (request.UserId ?? string.Empty).Trim(); + var token = request.Token ?? string.Empty; + + if (userId.Length == 0) return BadRequest("UserId is required."); + if (token.Length == 0) return BadRequest("Token is required."); + + var user = await _users.FindByIdAsync(userId); + if (user is null) return BadRequest("Invalid or expired link."); + + var res = await _users.ConfirmEmailAsync(user, token); + if (!res.Succeeded) + { + return BadRequest("Invalid or expired link."); + } + + return NoContent(); + } + + public sealed record ResendVerificationEmailRequest(string Email); + + [HttpPost("resend-verification-email")] + [AllowAnonymous] + [EnableRateLimiting("auth-email")] + public async Task ResendVerificationEmail([FromBody] ResendVerificationEmailRequest request, CancellationToken cancellationToken) + { + var email = (request.Email ?? string.Empty).Trim(); + if (email.Length == 0) return NoContent(); + + // Mirrors request-password-reset's enumeration-avoidance: always NoContent, only actually + // send when there's a matching local account that still needs verifying. + var user = await _users.FindByEmailAsync(email); + if (user is null || user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email) || !await _users.HasPasswordAsync(user)) + { + return NoContent(); + } + + try + { + await SendVerificationEmailAsync(user, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send verification email to {Email}", user.Email); + return EmailDeliveryUnavailable("Verification email could not be sent right now. Please try again later."); + } + + return NoContent(); + } + + private async Task SendVerificationEmailAsync(ApplicationUser user, CancellationToken cancellationToken) + { + var token = await _users.GenerateEmailConfirmationTokenAsync(user); + + var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/'); + if (string.IsNullOrWhiteSpace(baseUrl)) + { + baseUrl = $"{Request.Scheme}://{Request.Host}"; + } + + var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}"; + + await _email.SendAsync( + user.Email!, + "Verify your email", + $"Welcome to Jobbjakt! Please verify your email address to finish setting up your account.\n\nVerification link:\n{link}\n\nIf you did not create this account, you can ignore this email.", + cancellationToken + ); + } + private IActionResult EmailDeliveryUnavailable(string detail) { return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); diff --git a/JobTrackerApi/appsettings.Development.json b/JobTrackerApi/appsettings.Development.json index 43d7dcd..e3736e8 100644 --- a/JobTrackerApi/appsettings.Development.json +++ b/JobTrackerApi/appsettings.Development.json @@ -20,6 +20,7 @@ "Auth": { "Require": true, "AllowRegistration": true, + "RequireEmailVerification": false, "JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET", "JwtIssuer": "JobTrackerApi", "JwtAudience": "job-tracker-ui", diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index bb8e0cd..18407a2 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -31,6 +31,7 @@ import LoginPage from "./views/LoginPage"; import LandingPage from "./views/LandingPage"; import ForgotPasswordPage from "./views/ForgotPasswordPage"; import ResetPasswordPage from "./views/ResetPasswordPage"; +import VerifyEmailPage from "./views/VerifyEmailPage"; import RouteErrorPage from "./views/RouteErrorPage"; import { api } from "./api"; import { resolveCaptureUrl } from "./captureUrl"; @@ -366,6 +367,7 @@ export default function App() { { path: "/login", element: , errorElement: }, { path: "/forgot-password", element: , errorElement: }, { path: "/reset-password", element: , errorElement: }, + { path: "/verify-email", element: , errorElement: }, { path: "/*", element: , errorElement: }, ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index efa0f89..adee5f9 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -764,6 +764,15 @@ export const translations = { resetFailed: "Reset failed.", backToLogin: "Back to login", updatePassword: "Update password", + emailNotVerified: "Please verify your email address before signing in.", + resendVerificationEmail: "Resend verification email", + verificationEmailResent: "Verification email sent. Check your inbox.", + registerCheckEmailForVerification: "Check your email to verify your account.", + verifyEmailTitle: "Verify your email", + verifyEmailVerifying: "Verifying your email...", + verifyEmailSuccess: "Your email has been verified. You can now sign in.", + verifyEmailFailed: "This verification link is invalid or has expired.", + missingVerifyLinkInfo: "Missing user/token in link.", jobTableSearch: "Search", jobTableSearchPlaceholder: "Title, company, notes, messages", jobTableStatus: "Status", @@ -1792,6 +1801,15 @@ export const translations = { resetFailed: "Tilbakestilling mislyktes.", backToLogin: "Tilbake til innlogging", updatePassword: "Oppdater passord", + emailNotVerified: "Vennligst bekreft e-postadressen din før du logger inn.", + resendVerificationEmail: "Send bekreftelses-e-post på nytt", + verificationEmailResent: "Bekreftelses-e-post sendt. Sjekk innboksen din.", + registerCheckEmailForVerification: "Sjekk e-posten din for å bekrefte kontoen.", + verifyEmailTitle: "Bekreft e-posten din", + verifyEmailVerifying: "Bekrefter e-posten din...", + verifyEmailSuccess: "E-posten din er bekreftet. Du kan nå logge inn.", + verifyEmailFailed: "Denne bekreftelseslenken er ugyldig eller har utløpt.", + missingVerifyLinkInfo: "Mangler bruker/token i lenken.", jobTableSearch: "Søk", jobTableSearchPlaceholder: "Tittel, selskap, notater, meldinger", jobTableStatus: "Status", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 2973f34..5323e12 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -137,4 +137,33 @@ describe('LoginPage', () => { expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Please wait a few minutes and try again.'); }); + + it('offers a resend-verification action when login reports the account is not verified', async () => { + mockedApi.get.mockResolvedValueOnce({ + data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true }, + } as any); + mockedApi.post.mockImplementation((url: string) => { + if (url === '/auth/login') { + return Promise.reject({ response: { status: 403, data: { error: 'email_not_verified' } } }); + } + if (url === '/auth/resend-verification-email') { + return Promise.resolve({ data: {} } as any); + } + return Promise.resolve({ data: {} } as any); + }); + + renderLoginPage(); + await screen.findByLabelText('Email'); + + await userEvent.type(screen.getByLabelText('Email'), 'unverified@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); + + expect(await screen.findByText('Please verify your email address before signing in.')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Resend verification email' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/resend-verification-email', { email: 'unverified@example.com' })); + await screen.findByText('Verification email sent. Check your inbox.'); + }); }); diff --git a/job-tracker-ui/src/verify-email-page.test.tsx b/job-tracker-ui/src/verify-email-page.test.tsx new file mode 100644 index 0000000..4de708e --- /dev/null +++ b/job-tracker-ui/src/verify-email-page.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import VerifyEmailPage from './views/VerifyEmailPage'; +import { I18nProvider } from './i18n/I18nProvider'; +import { api, getApiErrorMessage } from './api'; + +const mockedApi = api as jest.Mocked; +// CRA's jest config sets resetMocks: true, which wipes the initial implementation given to +// jest.fn() in setupTests.ts before every test -- re-arm it here so error-derived text is testable. +const mockedGetApiErrorMessage = getApiErrorMessage as jest.Mock; + +function renderVerifyEmailPage(search: string) { + window.history.pushState({}, '', `/verify-email${search}`); + return render( + + + + + , + ); +} + +describe('VerifyEmailPage', () => { + beforeEach(() => { + mockedApi.post.mockReset(); + mockedGetApiErrorMessage.mockImplementation((e: any, fallback?: string) => { + const data = e?.response?.data; + return typeof data === 'string' && data.trim() ? data.trim() : fallback; + }); + }); + + it('confirms the account and shows success when the link is valid', async () => { + mockedApi.post.mockResolvedValueOnce({ data: {} } as any); + + renderVerifyEmailPage('?userId=user-1&token=good-token'); + + expect(await screen.findByText('Your email has been verified. You can now sign in.')).toBeInTheDocument(); + expect(mockedApi.post).toHaveBeenCalledWith('/auth/verify-email', { userId: 'user-1', token: 'good-token' }); + }); + + it('shows an error when the link is invalid or expired', async () => { + mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: 'Invalid or expired link.' } }); + + renderVerifyEmailPage('?userId=user-1&token=bad-token'); + + expect(await screen.findByText('Invalid or expired link.')).toBeInTheDocument(); + }); + + it('shows an error without calling the API when the link is missing userId/token', async () => { + renderVerifyEmailPage(''); + + expect(await screen.findByText('Missing user/token in link.')).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + }); +}); diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index b119c59..f5dff1d 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; import { useLocation, useNavigate } from "react-router-dom"; @@ -18,6 +18,7 @@ type AuthConfig = { microsoftEnabled: boolean; localEnabled: boolean; allowRegistration: boolean; + requireEmailVerification: boolean; }; export default function LoginPage() { @@ -34,6 +35,9 @@ export default function LoginPage() { const [rememberMe, setRememberMe] = useState(() => getRememberMePref()); const [loading, setLoading] = useState(false); const [pendingToken, setPendingToken] = useState(null); + const [emailNotVerified, setEmailNotVerified] = useState(false); + const [resendingVerification, setResendingVerification] = useState(false); + const [verificationResent, setVerificationResent] = useState(false); const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard"; @@ -53,6 +57,8 @@ export default function LoginPage() { async function submit(mode: "login" | "register") { setLoading(true); + setEmailNotVerified(false); + setVerificationResent(false); try { const url = mode === "register" ? "/auth/register" : "/auth/login"; const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, { email, password, rememberMe }); @@ -61,13 +67,33 @@ export default function LoginPage() { return; } await completeLogin(); + if (mode === "register" && cfg?.requireEmailVerification) { + toast(t("registerCheckEmailForVerification"), "info"); + } } catch (e: any) { - toast(getApiErrorMessage(e, t("loginFailed")), "error"); + if (mode === "login" && e?.response?.data?.error === "email_not_verified") { + setEmailNotVerified(true); + } else { + toast(getApiErrorMessage(e, t("loginFailed")), "error"); + } } finally { setLoading(false); } } + async function resendVerification() { + setResendingVerification(true); + try { + await api.post("/auth/resend-verification-email", { email }); + setVerificationResent(true); + toast(t("verificationEmailResent"), "success"); + } catch (e: any) { + toast(getApiErrorMessage(e, t("verifyEmailFailed")), "error"); + } finally { + setResendingVerification(false); + } + } + const allowReg = cfg?.allowRegistration ?? false; return ( @@ -106,6 +132,18 @@ export default function LoginPage() { {tab === 0 && ( { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> + {cfg?.requireEmailVerification && emailNotVerified && ( + void resendVerification()}> + {verificationResent ? t("verificationEmailResent") : t("resendVerificationEmail")} + + } + > + {t("emailNotVerified")} + + )} setEmail(e.target.value)} autoComplete="email" fullWidth /> setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth /> diff --git a/job-tracker-ui/src/views/VerifyEmailPage.tsx b/job-tracker-ui/src/views/VerifyEmailPage.tsx new file mode 100644 index 0000000..2524d0e --- /dev/null +++ b/job-tracker-ui/src/views/VerifyEmailPage.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; + +import { Alert, Box, Button, CircularProgress, Paper, Typography } from "@mui/material"; + +import { useNavigate } from "react-router-dom"; + +import { api, getApiErrorMessage } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; + +type Status = "verifying" | "success" | "error"; + +export default function VerifyEmailPage() { + const { t } = useI18n(); + const navigate = useNavigate(); + + const [status, setStatus] = useState("verifying"); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const userId = params.get("userId") || ""; + const token = params.get("token") || ""; + + if (!userId || !token) { + setStatus("error"); + setErrorMessage(t("missingVerifyLinkInfo")); + return; + } + + api + .post("/auth/verify-email", { userId, token }) + .then(() => setStatus("success")) + .catch((e: any) => { + setStatus("error"); + setErrorMessage(getApiErrorMessage(e, t("verifyEmailFailed"))); + }); + }, [t]); + + return ( + + + + {t("verifyEmailTitle")} + + + + {status === "verifying" && ( + + + {t("verifyEmailVerifying")} + + )} + {status === "success" && {t("verifyEmailSuccess")}} + {status === "error" && {errorMessage}} + + + + + + + + ); +}