From 946a55d7e7d479522ab2ce140a58fc6b59e0ad71 Mon Sep 17 00:00:00 2001 From: bitbiter-dev Date: Fri, 30 Oct 2026 20:21:32 +1300 Subject: [PATCH 1/3] feat(ui): Sign in from the login screen (#360) A user signs in at /login with username or email and password and lands on the signed-in placeholder at /, which sends a visitor without a token back to /login. React Hook Form and zod/mini validate the form. src/api/ calls POST /api/v1/auth/login, keeps the access token in memory only, and reads the Problem Details: code, field errors and Retry-After. Each code maps to a catalog message; an unknown code, an unreadable body or a network failure shows the generic one. A locked or rate-limited response counts down from Retry-After and blocks the button until it ends. zod/mini instead of zod: the full build put initial JS at 148.5 of the 150 kB budget; it is 130.5 kB now. zod's eval probe is turned off in main.tsx, because the CSP forbids eval. End-to-end flows: success, wrong password, disabled account, and locked account with its countdown, in all three browsers. No public endpoint creates invites (#107 is deferred), so users are seeded through the admin API, as decided on #360. The global setup rotates the bootstrap admin's password and fails on a database that is not fresh. Each test creates its own user and changes the temporary password. Every e2e request reaches the API from the one Docker bridge address, so the Sensitive rate limit (10 per minute per client) would stop the suite within seconds. Its permit limit becomes the validated setting RateLimit:SensitivePermitLimit. The default is unchanged; only the e2e compose file raises it. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- docs/architecture/ui-serving.md | 11 +- .../Infrastructure/AuthenticatedTestApp.cs | 2 +- .../ForwardedHeadersRateLimitTests.cs | 30 ++- .../ServiceCollectionExtensions.cs | 20 +- .../Settings/RateLimitSettings.cs | 9 + src/Anichron.UI/e2e/accounts.ts | 83 ++++++++ src/Anichron.UI/e2e/global-setup.ts | 17 ++ src/Anichron.UI/e2e/login.spec.ts | 88 ++++++++ src/Anichron.UI/e2e/smoke.spec.ts | 6 +- src/Anichron.UI/eslint.config.js | 7 + src/Anichron.UI/package.json | 5 +- src/Anichron.UI/playwright.config.ts | 1 + src/Anichron.UI/pnpm-lock.yaml | 113 ++++++++++ src/Anichron.UI/src/api/client.ts | 30 +++ src/Anichron.UI/src/api/login-contract.ts | 10 + src/Anichron.UI/src/api/problem.ts | 41 ++++ src/Anichron.UI/src/app/main.tsx | 7 +- src/Anichron.UI/src/app/router.tsx | 26 ++- .../src/features/login/LoginPage.test.tsx | 195 ++++++++++++++++++ .../src/features/login/LoginPage.tsx | 70 +++++++ .../src/features/login/messages.ts | 19 ++ .../src/features/login/use-login-form.ts | 50 +++++ .../placeholder/PlaceholderPage.test.tsx | 15 +- src/Anichron.UI/src/i18n/locales/en.json | 18 +- src/Anichron.UI/src/shared/use-countdown.ts | 28 +++ src/Anichron.UI/src/test/handlers/login.ts | 38 ++++ src/Anichron.UI/src/test/render-app.tsx | 8 +- src/docker-compose.e2e.yml | 1 + 29 files changed, 918 insertions(+), 34 deletions(-) create mode 100644 src/Anichron.API/Settings/RateLimitSettings.cs create mode 100644 src/Anichron.UI/e2e/accounts.ts create mode 100644 src/Anichron.UI/e2e/global-setup.ts create mode 100644 src/Anichron.UI/e2e/login.spec.ts create mode 100644 src/Anichron.UI/src/api/client.ts create mode 100644 src/Anichron.UI/src/api/login-contract.ts create mode 100644 src/Anichron.UI/src/api/problem.ts create mode 100644 src/Anichron.UI/src/features/login/LoginPage.test.tsx create mode 100644 src/Anichron.UI/src/features/login/LoginPage.tsx create mode 100644 src/Anichron.UI/src/features/login/messages.ts create mode 100644 src/Anichron.UI/src/features/login/use-login-form.ts create mode 100644 src/Anichron.UI/src/shared/use-countdown.ts create mode 100644 src/Anichron.UI/src/test/handlers/login.ts diff --git a/CLAUDE.md b/CLAUDE.md index cd736771..7f740372 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ The API's HTTP surface is `AddApiSurface` plus `MapApiEndpoints`; `Program.cs` a - **Proxy orphans** (`ProxyOrphans`, a proxy sweep's report) are written only by the Worker; outside it only `EfProxyOrphanReader` depends on the entity (`only-worker-writes-proxy-orphans`). See `docs/architecture/work-queue.md`, "Proxy sweeps". - Both hosts apply EF migrations at startup, serialised by a PostgreSQL advisory lock (`MigrateWithAdvisoryLockAsync`). -`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite. The API image builds it and serves it on the same origin as `/api/v1`, with immutable hashed assets, an `index.html` fallback that never answers `/api`, and a strict CSP with Trusted Types (ADR 0012, `docs/architecture/ui-serving.md`). It is a pnpm project, not part of the .NET solution; so far it has a translated placeholder page. The work is planned in parent issues #344–#349; see "The UI" below. +`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite. The API image builds it and serves it on the same origin as `/api/v1`, with immutable hashed assets, an `index.html` fallback that never answers `/api`, and a strict CSP with Trusted Types (ADR 0012, `docs/architecture/ui-serving.md`). It is a pnpm project, not part of the .NET solution; so far it has the login screen and a signed-in placeholder (#360). The work is planned in parent issues #344–#349; see "The UI" below. ### Storage Layout @@ -167,7 +167,7 @@ Each user has a private set of `UserStorageConfig` entries pointing to NAS roots - Merging a PR: wait until CI has finished and `CI gate` and `CodeQL` are green (`gh pr checks --watch`), then ask the maintainer to confirm. Never merge before CI passes, and never without that confirmation. - PostgreSQL connection is never hardcoded — always read from `POSTGRES_CONNECTION__*` env vars or Docker secrets - CORS allowed origins configured via `CORS__ALLOWEDORIGINS` env var (comma-separated); the same value switches the refresh cookie to `SameSite=None`. Leave empty for same-origin / reverse proxy deployments. -- `X-Forwarded-For` is honoured only from loopback or from addresses listed in `FORWARDEDHEADERS__KNOWNPROXIES` / `FORWARDEDHEADERS__KNOWNNETWORKS` (comma-separated IPs / CIDRs; `ForwardLimit = 1`). Rate limiting partitions on the resulting client IP, so a reverse-proxy deployment must set one of these or all clients share the proxy's bucket. Never trust the Docker bridge network for a directly published port — that lets any client spoof its IP. +- `X-Forwarded-For` is honoured only from loopback or from addresses listed in `FORWARDEDHEADERS__KNOWNPROXIES` / `FORWARDEDHEADERS__KNOWNNETWORKS` (comma-separated IPs / CIDRs; `ForwardLimit = 1`). Rate limiting partitions on the resulting client IP, so a reverse-proxy deployment must set one of these or all clients share the proxy's bucket. Never trust the Docker bridge network for a directly published port — that lets any client spoof its IP. The `Sensitive` policy (login, register, change-password, admin user writes) allows `RATELIMIT__SENSITIVEPERMITLIMIT` requests per minute (default 10); only `src/docker-compose.e2e.yml` raises it. - Registration requires an admin-issued invite token. The first user (bootstrap admin) is exempt. - Observability: `GET /api/v1/healthz` (database, proxy storage, originals storage; a missing mount reports Degraded). Logging is the default .NET console logger with `LoggerMessage` source-generated messages (architecture rule `logging-is-source-generated`; `BeginScope` is allowed); structured JSON output and OpenTelemetry are **not** wired up. - License: AGPL-3.0. diff --git a/docs/architecture/ui-serving.md b/docs/architecture/ui-serving.md index c4b00cb3..9de18bf3 100644 --- a/docs/architecture/ui-serving.md +++ b/docs/architecture/ui-serving.md @@ -61,5 +61,12 @@ for the flashback player, say) adds it to `Permissions-Policy` in its own change - **Seam 4** (`src/Anichron.UI/e2e/`): Playwright against the image built from the checkout, started by `src/docker-compose.e2e.yml` with PostgreSQL on tmpfs and no Worker. Projects: WebKit on an iPhone profile, Chromium on a Pixel profile, Firefox at a phone viewport. The smoke test - loads the placeholder and asserts the headers, the assets' caching, no CSP or Trusted Types - violation, no request to another origin, and no axe violations. CI runs it in the `E2E` job. + loads the app (a visitor without a token lands on `/login`) and asserts the headers, the assets' + caching, no CSP or Trusted Types violation, no request to another origin, and no axe violations. + `login.spec.ts` signs in through the screen: success, a wrong password, a disabled account and a + locked account's countdown, each with no CSP violation. Users are seeded only through the public + API (#360): the global setup rotates the bootstrap admin's first password (so it fails on a + database that is not fresh), and each test creates its own user with the admin API and changes + its temporary password. Every request reaches the API from the one Docker bridge address, so the + compose file raises `RateLimit__SensitivePermitLimit`; lockout keeps its production timings. CI + runs both in the `E2E` job. diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs b/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs index 3112baf0..6752cb96 100644 --- a/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs +++ b/src/Anichron.API.Tests.Unit/Infrastructure/AuthenticatedTestApp.cs @@ -37,7 +37,7 @@ public static async Task StartAsync( builder.Services.AddAuthentication("Test").AddScheme("Test", null); builder.Services.AddAuthorizationPolicies(); if (rateLimited) - builder.Services.AddRateLimiting(); + builder.Services.AddRateLimiting(builder.Configuration); configureServices(builder.Services); var app = builder.Build(); if (rateLimited) diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs b/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs index d029e782..af9bb749 100644 --- a/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs +++ b/src/Anichron.API.Tests.Unit/Infrastructure/ForwardedHeadersRateLimitTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using System.Net; namespace Anichron.API.Tests.Unit.Infrastructure; @@ -22,7 +23,7 @@ private static async Task StartAsync(Dictionary builder.Configuration.AddInMemoryCollection(settings); builder.Services .AddForwardedHeadersSupport(builder.Configuration) - .AddRateLimiting(); + .AddRateLimiting(builder.Configuration); var app = builder.Build(); app.Use((context, next) => @@ -113,6 +114,33 @@ public async Task RefreshPolicy_AllowsItsPermitLimitThenRejectsAsync() HttpStatusCode.TooManyRequests); } + [Fact] + public async Task SensitivePolicy_ConfiguredPermitLimit_AllowsThatManyThenRejectsAsync() + { + await using var app = await StartAsync(new() { ["RateLimit:SensitivePermitLimit"] = "3" }); + using var client = app.GetTestClient(); + var statuses = new List(); + for (var i = 0; i < 4; i++) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/probe"); + request.Headers.Add(PeerHeader, UntrustedPeer); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + statuses.Add(response.StatusCode); + } + + statuses.Should().Equal(HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + public async Task SensitivePolicy_PermitLimitBelowOne_FailsAtStartupAsync(string limit) + { + var start = () => StartAsync(new() { ["RateLimit:SensitivePermitLimit"] = limit }); + + await start.Should().ThrowAsync(); + } + [Fact] public void AddForwardedHeadersSupport_UnparseableNetwork_ThrowsAtRegistration() { diff --git a/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs b/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs index d0f592f9..5b34cd5f 100644 --- a/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Anichron.Infrastructure.Storage; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -30,7 +31,7 @@ public IServiceCollection AddApiSurface(IConfiguration configuration) .AddAuthServices(configuration) .AddWorkItemServices() .AddAuthorizationPolicies() - .AddRateLimiting() + .AddRateLimiting(configuration) .AddCorsPolicy(configuration) .AddApiHealthChecks(); @@ -61,21 +62,24 @@ public IServiceCollection AddForwardedHeadersSupport(IConfiguration configuratio }); } - public IServiceCollection AddRateLimiting() + public IServiceCollection AddRateLimiting(IConfiguration configuration) { - return services.AddRateLimiter(options => - { + services.AddOptions() + .Bind(configuration.GetSection("RateLimit")) + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddOptions().Configure>((options, settings) => options.AddPolicy(AuthRateLimitPolicies.Sensitive, new SlidingWindowPolicy( - AppDefaults.RateLimit.Sensitive.PermitLimit, + settings.Value.SensitivePermitLimit, TimeSpan.FromSeconds(AppDefaults.RateLimit.Sensitive.WindowSeconds), - AppDefaults.RateLimit.Sensitive.Segments)); + AppDefaults.RateLimit.Sensitive.Segments))); + return services.AddRateLimiter(options => // Refresh tokens rotate on every use. Tighter policy required. options.AddPolicy(AuthRateLimitPolicies.Refresh, new SlidingWindowPolicy( AppDefaults.RateLimit.Refresh.PermitLimit, TimeSpan.FromMinutes(AppDefaults.RateLimit.Refresh.WindowMinutes), - AppDefaults.RateLimit.Refresh.Segments)); - }); + AppDefaults.RateLimit.Refresh.Segments))); } public IServiceCollection AddWorkItemServices() diff --git a/src/Anichron.API/Settings/RateLimitSettings.cs b/src/Anichron.API/Settings/RateLimitSettings.cs new file mode 100644 index 00000000..318c5bd2 --- /dev/null +++ b/src/Anichron.API/Settings/RateLimitSettings.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Anichron.API.Settings; + +public sealed record RateLimitSettings +{ + [Range(1, int.MaxValue)] + public int SensitivePermitLimit { get; init; } = AppDefaults.RateLimit.Sensitive.PermitLimit; +} diff --git a/src/Anichron.UI/e2e/accounts.ts b/src/Anichron.UI/e2e/accounts.ts new file mode 100644 index 00000000..dca627eb --- /dev/null +++ b/src/Anichron.UI/e2e/accounts.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto'; +import { expect, type APIRequestContext } from '@playwright/test'; + +export const bootstrapAdmin = { username: 'admin', password: 'admin' }; +export const admin = { username: 'admin', password: 'end-to-end admin passphrase' }; + +export interface Account { + id: string; + username: string; + password: string; +} + +const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + +export async function signIn( + request: APIRequestContext, + usernameOrEmail: string, + password: string, +) { + const response = await request.post('/api/v1/auth/login/mobile', { + data: { usernameOrEmail, password }, + }); + expect(response.status(), await response.text()).toBe(200); + return ((await response.json()) as { accessToken: string }).accessToken; +} + +export async function changePassword( + request: APIRequestContext, + token: string, + currentPassword: string, + newPassword: string, +) { + const response = await request.post('/api/v1/users/me/password', { + headers: bearer(token), + data: { currentPassword, newPassword }, + }); + expect(response.status(), await response.text()).toBe(204); +} + +export async function createAccount(request: APIRequestContext): Promise { + const adminToken = await signIn(request, admin.username, admin.password); + const username = `user-${randomUUID().slice(0, 8)}`; + const created = await request.post('/api/v1/users', { + headers: bearer(adminToken), + data: { username, email: `${username}@example.com` }, + }); + expect(created.status(), await created.text()).toBe(201); + const { id, temporaryPassword } = (await created.json()) as { + id: string; + temporaryPassword: string; + }; + const password = `${username} passphrase`; + const token = await signIn(request, username, temporaryPassword); + await changePassword(request, token, temporaryPassword, password); + return { id, username, password }; +} + +export async function disable(request: APIRequestContext, account: Account) { + const adminToken = await signIn(request, admin.username, admin.password); + const response = await request.patch(`/api/v1/users/${account.id}`, { + headers: bearer(adminToken), + data: { isAdmin: null, isDisabled: true }, + }); + expect(response.status(), await response.text()).toBe(200); +} + +async function failSignIn(request: APIRequestContext, account: Account) { + const response = await request.post('/api/v1/auth/login/mobile', { + data: { usernameOrEmail: account.username, password: 'not the password' }, + }); + expect(response.status()).toBe(401); +} + +const pause = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + +// Failures 4, 5 and 6 lock for 2, 4 and 8 s; one during a lock is not counted. +export async function lockOut(request: APIRequestContext, account: Account) { + for (let attempt = 0; attempt < 4; attempt++) await failSignIn(request, account); + await pause(2); + await failSignIn(request, account); + await pause(4); + await failSignIn(request, account); +} diff --git a/src/Anichron.UI/e2e/global-setup.ts b/src/Anichron.UI/e2e/global-setup.ts new file mode 100644 index 00000000..ed71a794 --- /dev/null +++ b/src/Anichron.UI/e2e/global-setup.ts @@ -0,0 +1,17 @@ +import { request, type FullConfig } from '@playwright/test'; +import { admin, bootstrapAdmin, changePassword } from './accounts'; + +export default async function globalSetup(config: FullConfig) { + const context = await request.newContext({ baseURL: config.projects[0]?.use.baseURL }); + const response = await context.post('/api/v1/auth/login/mobile', { + data: { usernameOrEmail: bootstrapAdmin.username, password: bootstrapAdmin.password }, + }); + if (response.status() !== 200) + throw new Error( + 'The bootstrap admin cannot sign in with its first password, so the database is not fresh. ' + + 'Restart it: docker compose -f src/docker-compose.e2e.yml down, then up.', + ); + const { accessToken } = (await response.json()) as { accessToken: string }; + await changePassword(context, accessToken, bootstrapAdmin.password, admin.password); + await context.dispose(); +} diff --git a/src/Anichron.UI/e2e/login.spec.ts b/src/Anichron.UI/e2e/login.spec.ts new file mode 100644 index 00000000..3ba81c23 --- /dev/null +++ b/src/Anichron.UI/e2e/login.spec.ts @@ -0,0 +1,88 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; +import { createAccount, disable, lockOut } from './accounts'; + +const signedIn = "You're signed in. Your photo library is on its way."; +const pageErrors: string[] = []; + +test.beforeEach(async ({ page }) => { + pageErrors.length = 0; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.addInitScript(() => { + const violations: string[] = []; + Object.defineProperty(window, 'cspViolations', { value: violations }); + document.addEventListener('securitypolicyviolation', (event) => { + violations.push(`${event.effectiveDirective} ${event.blockedURI}`); + }); + }); +}); + +test.afterEach(async ({ page }) => { + expect(await page.evaluate(() => Reflect.get(window, 'cspViolations') as string[])).toEqual([]); + expect(pageErrors).toEqual([]); +}); + +async function signIn(page: Page, usernameOrEmail: string, password: string) { + await page.goto('/login'); + await page.getByLabel('Username or email').fill(usernameOrEmail); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); +} + +async function expectAccessible(page: Page) { + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations).toEqual([]); +} + +test('a user signs in and lands on the signed-in page', async ({ page, request }) => { + const account = await createAccount(request); + + await signIn(page, account.username, account.password); + + await expect(page.getByText(signedIn)).toBeVisible(); + await expect(page).toHaveURL('/'); + await expectAccessible(page); +}); + +test('a wrong password is refused in the user’s language', async ({ page, request }) => { + const account = await createAccount(request); + + await signIn(page, account.username, 'not the password'); + + await expect(page.getByRole('alert')).toHaveText('The username, email or password is wrong.'); + await expect(page).toHaveURL('/login'); + await expectAccessible(page); +}); + +test('a disabled account is told to ask the administrator', async ({ page, request }) => { + const account = await createAccount(request); + await disable(request, account); + + await signIn(page, account.username, account.password); + + await expect(page.getByRole('alert')).toHaveText( + 'This account is disabled. Ask your administrator to enable it.', + ); + await expect(page).toHaveURL('/login'); +}); + +test('a locked account counts down, then signs in', async ({ page, request }) => { + test.slow(); + const account = await createAccount(request); + await lockOut(request, account); + + await signIn(page, account.username, account.password); + + const alert = page.getByRole('alert'); + await expect(alert).toContainText('Too many failed attempts. This account is locked for now.'); + await expect(alert).toContainText(/You can try again in \d+ seconds?\./); + const submit = page.getByRole('button', { name: 'Sign in' }); + await expect(submit).toBeDisabled(); + await expectAccessible(page); + + await expect(submit).toBeEnabled({ timeout: 10_000 }); + await expect(alert).not.toContainText('You can try again'); + await submit.click(); + + await expect(page.getByText(signedIn)).toBeVisible(); +}); diff --git a/src/Anichron.UI/e2e/smoke.spec.ts b/src/Anichron.UI/e2e/smoke.spec.ts index 72f0faca..6a4981e8 100644 --- a/src/Anichron.UI/e2e/smoke.spec.ts +++ b/src/Anichron.UI/e2e/smoke.spec.ts @@ -7,7 +7,7 @@ const appPolicy = "font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; " + "frame-ancestors 'none'; require-trusted-types-for 'script'"; -test('the placeholder loads with its headers and requests nothing but its own origin', async ({ +test('the app loads with its headers and requests nothing but its own origin', async ({ page, context, baseURL, @@ -32,8 +32,8 @@ test('the placeholder loads with its headers and requests nothing but its own or const response = await page.goto('/'); - await expect(page.getByRole('heading', { level: 1, name: 'Anichron' })).toBeVisible(); - await expect(page.getByText('Your photo library is on its way.')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'Sign in to Anichron' })).toBeVisible(); + await expect(page).toHaveURL('/login'); const headers = response?.headers() ?? {}; expect(headers['content-security-policy']).toBe(appPolicy); expect(headers['referrer-policy']).toBe('same-origin'); diff --git a/src/Anichron.UI/eslint.config.js b/src/Anichron.UI/eslint.config.js index 69741ebc..201bfc75 100644 --- a/src/Anichron.UI/eslint.config.js +++ b/src/Anichron.UI/eslint.config.js @@ -34,6 +34,13 @@ export default defineConfig([ 'error', { allowInterfaces: 'with-single-extends' }, ], + '@typescript-eslint/only-throw-error': [ + 'error', + { + allow: [{ from: 'package', package: '@tanstack/router-core', name: 'Redirect' }], + allowRethrowing: true, + }, + ], }, }, { diff --git a/src/Anichron.UI/package.json b/src/Anichron.UI/package.json index 2163ad94..a5786896 100644 --- a/src/Anichron.UI/package.json +++ b/src/Anichron.UI/package.json @@ -36,11 +36,14 @@ "last 2 Edge versions" ], "dependencies": { + "@hookform/resolvers": "6.0.2", "@tanstack/react-router": "1.170.46", "i18next": "26.5.11", "react": "19.3.1", "react-dom": "19.3.1", - "react-i18next": "17.1.9" + "react-hook-form": "7.91.0", + "react-i18next": "17.1.9", + "zod": "4.6.7" }, "devDependencies": { "@axe-core/playwright": "4.14.0", diff --git a/src/Anichron.UI/playwright.config.ts b/src/Anichron.UI/playwright.config.ts index 6e580f46..c52fe322 100644 --- a/src/Anichron.UI/playwright.config.ts +++ b/src/Anichron.UI/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', + globalSetup: './e2e/global-setup.ts', forbidOnly: !!process.env.CI, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { diff --git a/src/Anichron.UI/pnpm-lock.yaml b/src/Anichron.UI/pnpm-lock.yaml index 98159c75..de4b7ea3 100644 --- a/src/Anichron.UI/pnpm-lock.yaml +++ b/src/Anichron.UI/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: 6.0.2 + version: 6.0.2(ajv@8.20.0)(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(zod@4.6.7) '@tanstack/react-router': specifier: 1.170.46 version: 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -178,9 +181,15 @@ importers: react-dom: specifier: 19.3.1 version: 19.3.1(react@19.3.1) + react-hook-form: + specifier: 7.91.0 + version: 7.91.0(@types/react@19.3.0)(react@19.3.1) react-i18next: specifier: 17.1.9 version: 17.1.9(i18next@26.5.11(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3) + zod: + specifier: 4.6.7 + version: 4.6.7 devDependencies: '@axe-core/playwright': specifier: 4.14.0 @@ -647,6 +656,81 @@ packages: '@noble/hashes': optional: true + '@hookform/resolvers@6.0.2': + resolution: {integrity: sha512-e2dqUOlaGuirKVOd/iZWk/Yg5zeaGw7t0hSc3LzxL3o7eZGwZkTyEbBL8H52m0wRKEsNLWoMGOiXGHbPHTXlEQ==} + peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^1.23.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^4.0.0 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 || ^18.0.0 + nope-validator: '>=0.12.0' + react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=6.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1089,6 +1173,9 @@ packages: peerDependencies: size-limit: 14.3.0 + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stryker-mutator/api@10.1.1': resolution: {integrity: sha512-3s/ECsrD1Tf1WEB7amBk0HxjN7zgkabKvmqnI2C831rTNszJ6o2HoV9PVxtElDEWxb2+t4iavK5x25G+DTUOUA==} engines: {node: '>=22.0.0'} @@ -2663,6 +2750,16 @@ packages: peerDependencies: react: ^19.3.1 + react-hook-form@7.91.0: + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + react-i18next@17.1.9: resolution: {integrity: sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==} peerDependencies: @@ -3662,6 +3759,14 @@ snapshots: '@exodus/bytes@1.16.0': {} + '@hookform/resolvers@6.0.2(ajv@8.20.0)(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(zod@4.6.7)': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + ajv: 8.20.0 + zod: 4.6.7 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3963,6 +4068,8 @@ snapshots: dependencies: size-limit: 14.3.0 + '@standard-schema/utils@0.3.0': {} + '@stryker-mutator/api@10.1.1': dependencies: mutation-testing-metrics: 3.8.4 @@ -5579,6 +5686,12 @@ snapshots: react: 19.3.1 scheduler: 0.28.0 + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + react-i18next@17.1.9(i18next@26.5.11(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.10 diff --git a/src/Anichron.UI/src/api/client.ts b/src/Anichron.UI/src/api/client.ts new file mode 100644 index 00000000..7b7b2cd8 --- /dev/null +++ b/src/Anichron.UI/src/api/client.ts @@ -0,0 +1,30 @@ +import type { LoginRequest, LoginResponse } from './login-contract'; +import { readProblem, unknownProblem, type Problem } from './problem'; + +export type LoginOutcome = { ok: true } | { ok: false; problem: Problem }; + +export interface ApiClient { + login(request: LoginRequest): Promise; + isSignedIn(): boolean; +} + +export function createApiClient(): ApiClient { + let accessToken: string | undefined; + return { + async login(request) { + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + if (!response.ok) return { ok: false, problem: await readProblem(response) }; + accessToken = ((await response.json()) as LoginResponse).accessToken; + return { ok: true }; + } catch { + return { ok: false, problem: unknownProblem }; + } + }, + isSignedIn: () => accessToken !== undefined, + }; +} diff --git a/src/Anichron.UI/src/api/login-contract.ts b/src/Anichron.UI/src/api/login-contract.ts new file mode 100644 index 00000000..6c5759ef --- /dev/null +++ b/src/Anichron.UI/src/api/login-contract.ts @@ -0,0 +1,10 @@ +// Hand-written until #347 generates the API types; delete together with src/test/handlers/login.ts. + +export interface LoginRequest { + usernameOrEmail: string; + password: string; +} + +export interface LoginResponse { + accessToken: string; +} diff --git a/src/Anichron.UI/src/api/problem.ts b/src/Anichron.UI/src/api/problem.ts new file mode 100644 index 00000000..d3a0f423 --- /dev/null +++ b/src/Anichron.UI/src/api/problem.ts @@ -0,0 +1,41 @@ +import { z } from 'zod/mini'; + +export interface Problem { + code: string | undefined; + fields: Readonly>>; + retryAfterSeconds: number; +} + +const problemBody = z.catch( + z.object({ + code: z.catch(z.optional(z.string()), undefined), + errors: z.catch( + z.record( + z.string(), + z.pipe( + z.tuple([z.string()], z.string()), + z.transform(([first]) => first), + ), + ), + {}, + ), + }), + { code: undefined, errors: {} }, +); + +export const unknownProblem: Problem = { code: undefined, fields: {}, retryAfterSeconds: 0 }; + +export async function readProblem(response: Response): Promise { + const body = problemBody.parse(await response.json().catch(() => undefined)); + return { + code: body.code, + fields: body.errors, + retryAfterSeconds: Number(response.headers.get('Retry-After')) || 0, + }; +} + +export function fieldCodes(problem: Problem, fields: readonly F[]) { + return fields + .filter((field) => field in problem.fields) + .map((field) => [field, problem.fields[field]] as const); +} diff --git a/src/Anichron.UI/src/app/main.tsx b/src/Anichron.UI/src/app/main.tsx index 807130f3..a3db1021 100644 --- a/src/Anichron.UI/src/app/main.tsx +++ b/src/Anichron.UI/src/app/main.tsx @@ -1,15 +1,20 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { z } from 'zod/mini'; +import { createApiClient } from '../api/client'; import { createI18n } from '../i18n/i18n'; import { App } from './App'; import './base.css'; import { createAppRouter } from './router'; +// The CSP forbids the eval that zod probes for. +z.config({ jitless: true }); + const root = document.getElementById('root'); if (root) { createRoot(root).render( - + , ); } diff --git a/src/Anichron.UI/src/app/router.tsx b/src/Anichron.UI/src/app/router.tsx index 558ae4bc..450129ad 100644 --- a/src/Anichron.UI/src/app/router.tsx +++ b/src/Anichron.UI/src/app/router.tsx @@ -1,23 +1,39 @@ import { - createRootRoute, + createRootRouteWithContext, createRoute, createRouter, + redirect, type RouterHistory, } from '@tanstack/react-router'; +import type { ApiClient } from '../api/client'; +import { LoginPage } from '../features/login/LoginPage'; import { PlaceholderPage } from '../features/placeholder/PlaceholderPage'; -const rootRoute = createRootRoute(); +interface RouterContext { + api: ApiClient; +} + +const rootRoute = createRootRouteWithContext()(); const placeholderRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', + beforeLoad: ({ context }) => { + if (!context.api.isSignedIn()) throw redirect({ to: '/login' }); + }, component: PlaceholderPage, }); -const routeTree = rootRoute.addChildren([placeholderRoute]); +const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, +}); + +const routeTree = rootRoute.addChildren([placeholderRoute, loginRoute]); -export function createAppRouter(history?: RouterHistory) { - return createRouter({ routeTree, history }); +export function createAppRouter(api: ApiClient, history?: RouterHistory) { + return createRouter({ routeTree, history, context: { api } }); } declare module '@tanstack/react-router' { diff --git a/src/Anichron.UI/src/features/login/LoginPage.test.tsx b/src/Anichron.UI/src/features/login/LoginPage.test.tsx new file mode 100644 index 00000000..274be63c --- /dev/null +++ b/src/Anichron.UI/src/features/login/LoginPage.test.tsx @@ -0,0 +1,195 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { HttpResponse } from 'msw'; +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { LoginRequest } from '../../api/login-contract'; +import { axeResults } from '../../test/axe'; +import { loginFails, loginRespondsWith, loginSucceeds, problem } from '../../test/handlers/login'; +import { renderApp } from '../../test/render-app'; +import { server } from '../../test/server'; + +const serverText = 'Server text that is never shown.'; + +async function openLogin() { + const rendered = renderApp('/login'); + await screen.findByRole('heading', { level: 1, name: 'Sign in to Anichron' }); + return rendered; +} + +function signIn(usernameOrEmail: string, password: string) { + fireEvent.change(screen.getByLabelText('Username or email'), { + target: { value: usernameOrEmail }, + }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: password } }); + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); +} + +async function alertSays(message: string) { + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(message); + }); +} + +const submitButton = () => screen.getByRole('button', { name: 'Sign in' }); + +describe('the login screen', () => { + it('is labelled and accessible', async () => { + const { container } = await openLogin(); + + expect(screen.getByLabelText('Username or email')).toHaveAttribute('autocomplete', 'username'); + expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password'); + expect(screen.getByLabelText('Password')).toHaveAttribute('autocomplete', 'current-password'); + expect(await axeResults(container)).toHaveNoViolations(); + }); + + it('signs in with the trimmed username and lands on the signed-in page', async () => { + const sent: LoginRequest[] = []; + server.use(loginSucceeds((request) => sent.push(request))); + const { router } = await openLogin(); + + signIn(' mira ', 'correct horse battery staple'); + + expect( + await screen.findByText("You're signed in. Your photo library is on its way."), + ).toBeVisible(); + expect(router.state.location.pathname).toBe('/'); + expect(sent).toEqual([{ usernameOrEmail: 'mira', password: 'correct horse battery staple' }]); + }); + + it('asks for both fields before sending anything', async () => { + const { container } = await openLogin(); + + signIn(' ', ''); + + await waitFor(() => { + expect(screen.getAllByText('Fill in this field.')).toHaveLength(2); + }); + expect(screen.getByLabelText('Username or email')).toHaveAccessibleDescription( + 'Fill in this field.', + ); + expect(screen.getByLabelText('Password')).toHaveAttribute('aria-invalid', 'true'); + expect(await axeResults(container)).toHaveNoViolations(); + }); + + it.each([ + [401, 'invalid_credentials', 'The username, email or password is wrong.'], + [401, 'account_disabled', 'This account is disabled. Ask your administrator to enable it.'], + [500, 'a_code_from_the_future', 'Something went wrong. Try again.'], + ])('shows a %i %s in the user’s language', async (status, code, message) => { + server.use(loginFails(status, problem(status, code))); + const { router } = await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays(message); + expect(screen.queryByText(serverText)).toBeNull(); + expect(router.state.location.pathname).toBe('/login'); + expect(submitButton()).toBeEnabled(); + expect(screen.getByLabelText('Username or email')).toHaveAttribute('aria-invalid', 'false'); + }); + + it.each([ + [ + 'a body that is not JSON', + () => new HttpResponse('Bad gateway', { status: 502 }), + ], + [ + 'a body that is not a problem', + () => HttpResponse.json(['invalid_credentials'], { status: 401 }), + ], + ['a network failure', () => HttpResponse.error()], + ])('shows the generic error for %s', async (_, response) => { + server.use(loginRespondsWith(response)); + await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Something went wrong. Try again.'); + expect(submitButton()).toBeEnabled(); + }); + + it('shows a field error from the API on its field', async () => { + server.use( + loginFails( + 422, + problem(422, 'a_code_from_the_future', { + errors: { password: ['invalid_credentials', 'a_code_from_the_future'], other: ['x'] }, + }), + ), + ); + await openLogin(); + + signIn('mira', 'short'); + + await waitFor(() => { + expect(screen.getByLabelText('Password')).toHaveAccessibleDescription( + 'The username, email or password is wrong.', + ); + }); + expect(screen.getByLabelText('Password')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText('Username or email')).not.toHaveAccessibleDescription(); + }); + + it.each([ + ['a single code', 'invalid_credentials'], + ['an empty list', []], + ])('ignores field errors given as %s', async (_, password) => { + server.use(loginFails(422, problem(422, 'invalid_credentials', { errors: { password } }))); + await openLogin(); + + signIn('mira', 'short'); + + await alertSays('The username, email or password is wrong.'); + expect(screen.getByLabelText('Password')).toHaveAttribute('aria-invalid', 'false'); + }); + + it('counts down a locked account from Retry-After, then accepts a new attempt', async () => { + server.use(loginFails(429, problem(429, 'account_locked'), { 'Retry-After': '3' })); + const { container } = await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Too many failed attempts. This account is locked for now.'); + expect(screen.getByText('You can try again in 3 seconds.')).toBeVisible(); + expect(submitButton()).toBeDisabled(); + + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(screen.getByText('You can try again in 2 seconds.')).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(screen.getByText('You can try again in 1 second.')).toBeVisible(); + expect(submitButton()).toBeDisabled(); + await act(() => vi.advanceTimersByTimeAsync(1000)); + + expect(screen.queryByText(/You can try again/)).toBeNull(); + expect(submitButton()).toBeEnabled(); + expect(await axeResults(container)).toHaveNoViolations(); + server.use(loginSucceeds()); + fireEvent.click(submitButton()); + expect( + await screen.findByText("You're signed in. Your photo library is on its way."), + ).toBeVisible(); + }); + + it('counts down a rate limit from Retry-After', async () => { + server.use(loginFails(429, problem(429, 'rate_limited'), { 'Retry-After': '60' })); + await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Too many attempts from this device.'); + expect(screen.getByText('You can try again in 60 seconds.')).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(59_000)); + expect(screen.getByText('You can try again in 1 second.')).toBeVisible(); + }); + + it('shows no countdown when Retry-After is missing or unreadable', async () => { + server.use(loginFails(429, problem(429, 'rate_limited'), { 'Retry-After': 'soon' })); + await openLogin(); + + signIn('mira', 'wrong'); + + await alertSays('Too many attempts from this device.'); + expect(screen.queryByText(/You can try again/)).toBeNull(); + expect(submitButton()).toBeEnabled(); + }); +}); diff --git a/src/Anichron.UI/src/features/login/LoginPage.tsx b/src/Anichron.UI/src/features/login/LoginPage.tsx new file mode 100644 index 00000000..c3264931 --- /dev/null +++ b/src/Anichron.UI/src/features/login/LoginPage.tsx @@ -0,0 +1,70 @@ +import type { UseFormRegisterReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import type { MessageKey } from './messages'; +import { useLoginForm } from './use-login-form'; + +export function LoginPage() { + const { t } = useTranslation(); + const { register, onSubmit, formError, fieldError, retryIn, blocked } = useLoginForm(); + return ( +
+

{t('login.title')}

+
void onSubmit(event)} noValidate> + + + + + +
+ ); +} + +function FormError({ error, retryIn }: { error: MessageKey | undefined; retryIn: number }) { + const { t } = useTranslation(); + return ( +
+ {error &&

{t(error)}

} + {retryIn > 0 &&

{t('login.retryIn', { count: retryIn })}

} +
+ ); +} + +interface FieldProps { + label: string; + type?: string; + autoComplete: string; + error: MessageKey | undefined; + registration: UseFormRegisterReturn; +} + +function Field({ label, type = 'text', autoComplete, error, registration }: FieldProps) { + const { t } = useTranslation(); + const errorId = `${registration.name}-error`; + return ( +
+ + + {error &&

{t(error)}

} +
+ ); +} diff --git a/src/Anichron.UI/src/features/login/messages.ts b/src/Anichron.UI/src/features/login/messages.ts new file mode 100644 index 00000000..9344db30 --- /dev/null +++ b/src/Anichron.UI/src/features/login/messages.ts @@ -0,0 +1,19 @@ +const keys = new Map([ + ['required', 'login.errors.required'], + ['invalid_credentials', 'login.errors.invalidCredentials'], + ['account_disabled', 'login.errors.accountDisabled'], + ['account_locked', 'login.errors.accountLocked'], + ['rate_limited', 'login.errors.rateLimited'], +]); + +export type MessageKey = + | 'login.errors.required' + | 'login.errors.invalidCredentials' + | 'login.errors.accountDisabled' + | 'login.errors.accountLocked' + | 'login.errors.rateLimited' + | 'login.errors.generic'; + +export function messageKey(code: string | undefined): MessageKey { + return keys.get(code) ?? 'login.errors.generic'; +} diff --git a/src/Anichron.UI/src/features/login/use-login-form.ts b/src/Anichron.UI/src/features/login/use-login-form.ts new file mode 100644 index 00000000..7b01e37d --- /dev/null +++ b/src/Anichron.UI/src/features/login/use-login-form.ts @@ -0,0 +1,50 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate, useRouter } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod/mini'; +import { fieldCodes } from '../../api/problem'; +import { useCountdown } from '../../shared/use-countdown'; +import { messageKey } from './messages'; + +const required = z.minLength(1, { error: 'required' }); + +const loginSchema = z.object({ + usernameOrEmail: z.string().check(z.trim(), required), + password: z.string().check(required), +}); + +type LoginFields = z.infer; + +const fields = ['usernameOrEmail', 'password'] as const; + +export function useLoginForm() { + const { api } = useRouter().options.context; + const navigate = useNavigate(); + const countdown = useCountdown(); + const { register, handleSubmit, setError, formState } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = handleSubmit(async (values) => { + const outcome = await api.login(values); + if (outcome.ok) { + await navigate({ to: '/' }); + return; + } + setError('root', { message: outcome.problem.code }); + for (const [field, code] of fieldCodes(outcome.problem, fields)) + setError(field, { message: code }); + countdown.start(outcome.problem.retryAfterSeconds); + }); + + const { errors, isSubmitting } = formState; + return { + register, + onSubmit, + formError: errors.root && messageKey(errors.root.message), + fieldError: (field: (typeof fields)[number]) => + errors[field] && messageKey(errors[field].message), + retryIn: countdown.remaining, + blocked: isSubmitting || countdown.remaining > 0, + }; +} diff --git a/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx b/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx index 462d9341..8e64b72e 100644 --- a/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx +++ b/src/Anichron.UI/src/features/placeholder/PlaceholderPage.test.tsx @@ -1,14 +1,15 @@ import { screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { axeResults } from '../../test/axe'; import { renderApp } from '../../test/render-app'; -describe('the placeholder page', () => { - it('greets the user in English at the root', async () => { - const { container } = renderApp('/'); +describe('the signed-in placeholder', () => { + it('sends a visitor without a token to the login screen', async () => { + const { router } = renderApp('/'); - expect(await screen.findByRole('heading', { level: 1, name: 'Anichron' })).toBeVisible(); - expect(screen.getByText('Your photo library is on its way.')).toBeVisible(); - expect(await axeResults(container)).toHaveNoViolations(); + expect( + await screen.findByRole('heading', { level: 1, name: 'Sign in to Anichron' }), + ).toBeVisible(); + expect(router.state.location.pathname).toBe('/login'); + expect(screen.queryByText("You're signed in. Your photo library is on its way.")).toBeNull(); }); }); diff --git a/src/Anichron.UI/src/i18n/locales/en.json b/src/Anichron.UI/src/i18n/locales/en.json index 87f67e71..9322bb2e 100644 --- a/src/Anichron.UI/src/i18n/locales/en.json +++ b/src/Anichron.UI/src/i18n/locales/en.json @@ -1,6 +1,22 @@ { "placeholder": { "title": "Anichron", - "body": "Your photo library is on its way." + "body": "You're signed in. Your photo library is on its way." + }, + "login": { + "title": "Sign in to Anichron", + "usernameOrEmail": "Username or email", + "password": "Password", + "submit": "Sign in", + "retryIn_one": "You can try again in {{count}} second.", + "retryIn_other": "You can try again in {{count}} seconds.", + "errors": { + "required": "Fill in this field.", + "invalidCredentials": "The username, email or password is wrong.", + "accountDisabled": "This account is disabled. Ask your administrator to enable it.", + "accountLocked": "Too many failed attempts. This account is locked for now.", + "rateLimited": "Too many attempts from this device.", + "generic": "Something went wrong. Try again." + } } } diff --git a/src/Anichron.UI/src/shared/use-countdown.ts b/src/Anichron.UI/src/shared/use-countdown.ts new file mode 100644 index 00000000..1a28c44e --- /dev/null +++ b/src/Anichron.UI/src/shared/use-countdown.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +const secondsUntil = (deadline: number) => Math.ceil((deadline - Date.now()) / 1000); + +export function useCountdown() { + const [deadline, setDeadline] = useState(); + const [remaining, setRemaining] = useState(0); + + useEffect(() => { + if (deadline === undefined) return undefined; + const timer = setInterval(() => { + const left = Math.max(0, secondsUntil(deadline)); + setRemaining(left); + if (left === 0) setDeadline(undefined); + }, 1000); + return () => { + clearInterval(timer); + }; + }, [deadline]); + + return { + remaining, + start(seconds: number) { + setDeadline(Date.now() + seconds * 1000); + setRemaining(seconds); + }, + }; +} diff --git a/src/Anichron.UI/src/test/handlers/login.ts b/src/Anichron.UI/src/test/handlers/login.ts new file mode 100644 index 00000000..183a9c2b --- /dev/null +++ b/src/Anichron.UI/src/test/handlers/login.ts @@ -0,0 +1,38 @@ +// Hand-written until #347 generates the API types; delete together with src/api/login-contract.ts. +import { http, HttpResponse } from 'msw'; +import type { LoginRequest, LoginResponse } from '../../api/login-contract'; + +const loginPath = '/api/v1/auth/login'; + +export function loginSucceeds(onRequest: (request: LoginRequest) => void = () => undefined) { + return http.post(loginPath, async ({ request }) => { + if (request.headers.get('Content-Type') !== 'application/json') + return HttpResponse.json(problem(415, 'unsupported_media_type'), { status: 415 }); + onRequest(await request.json()); + return HttpResponse.json({ accessToken: 'header.payload.signature' }); + }); +} + +export function loginFails(status: number, body: object, headers: Record = {}) { + return http.post(loginPath, () => + HttpResponse.json(body, { + status, + headers: { 'Content-Type': 'application/problem+json', ...headers }, + }), + ); +} + +export function problem(status: number, code: string, extra: object = {}) { + return { + type: 'https://tools.ietf.org/html/rfc9110#section-15.5.2', + title: 'Unauthorized', + status, + detail: 'Server text that is never shown.', + code, + ...extra, + }; +} + +export function loginRespondsWith(response: () => Response) { + return http.post(loginPath, response); +} diff --git a/src/Anichron.UI/src/test/render-app.tsx b/src/Anichron.UI/src/test/render-app.tsx index 03928094..d88efec3 100644 --- a/src/Anichron.UI/src/test/render-app.tsx +++ b/src/Anichron.UI/src/test/render-app.tsx @@ -1,10 +1,14 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { render } from '@testing-library/react'; +import { createApiClient } from '../api/client'; import { App } from '../app/App'; import { createAppRouter } from '../app/router'; import { createI18n } from '../i18n/i18n'; export function renderApp(path: string) { - const router = createAppRouter(createMemoryHistory({ initialEntries: [path] })); - return render(); + const router = createAppRouter( + createApiClient(), + createMemoryHistory({ initialEntries: [path] }), + ); + return { ...render(), router }; } diff --git a/src/docker-compose.e2e.yml b/src/docker-compose.e2e.yml index b5e018c9..6c719eda 100644 --- a/src/docker-compose.e2e.yml +++ b/src/docker-compose.e2e.yml @@ -40,6 +40,7 @@ services: - Jwt__Secret=end-to-end-only-secret-at-least-32-bytes-long - Jwt__Issuer=anichron - Jwt__Audience=anichron + - RateLimit__SensitivePermitLimit=10000 healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/healthz || exit 1"] interval: 2s From f1c03146e7bde80eabc447c55bd9566478e258f3 Mon Sep 17 00:00:00 2001 From: bitbiter-dev Date: Fri, 30 Oct 2026 20:32:36 +1300 Subject: [PATCH 2/3] refactor(ui): Address review of the login screen (#360) MessageKey is derived from the catalog, both rate-limit policies are configured in one place, the success and disabled-account flows are checked with axe, an e2e reload proves the token is not persisted, and the lockout pauses leave a margin past each lock. Co-Authored-By: Claude Opus 5.5 --- .../Infrastructure/ServiceCollectionExtensions.cs | 9 ++++++--- src/Anichron.UI/e2e/accounts.ts | 4 ++-- src/Anichron.UI/e2e/login.spec.ts | 4 ++++ src/Anichron.UI/src/features/login/LoginPage.test.tsx | 3 ++- src/Anichron.UI/src/features/login/messages.ts | 10 +++------- src/Anichron.UI/src/test/handlers/login.ts | 2 -- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs b/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs index c1cc9a5b..511d33c4 100644 --- a/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Anichron.API/Infrastructure/ServiceCollectionExtensions.cs @@ -70,17 +70,20 @@ public IServiceCollection AddRateLimiting(IConfiguration configuration) .ValidateDataAnnotations() .ValidateOnStart(); services.AddOptions().Configure>((options, settings) => + { options.AddPolicy(AuthRateLimitPolicies.Sensitive, new SlidingWindowPolicy( settings.Value.SensitivePermitLimit, TimeSpan.FromSeconds(AppDefaults.RateLimit.Sensitive.WindowSeconds), - AppDefaults.RateLimit.Sensitive.Segments))); + AppDefaults.RateLimit.Sensitive.Segments)); - return services.AddRateLimiter(options => // Refresh tokens rotate on every use. Tighter policy required. options.AddPolicy(AuthRateLimitPolicies.Refresh, new SlidingWindowPolicy( AppDefaults.RateLimit.Refresh.PermitLimit, TimeSpan.FromMinutes(AppDefaults.RateLimit.Refresh.WindowMinutes), - AppDefaults.RateLimit.Refresh.Segments))); + AppDefaults.RateLimit.Refresh.Segments)); + }); + + return services.AddRateLimiter(_ => { }); } public IServiceCollection AddWorkItemServices() diff --git a/src/Anichron.UI/e2e/accounts.ts b/src/Anichron.UI/e2e/accounts.ts index dca627eb..036cd034 100644 --- a/src/Anichron.UI/e2e/accounts.ts +++ b/src/Anichron.UI/e2e/accounts.ts @@ -76,8 +76,8 @@ const pause = (seconds: number) => new Promise((resolve) => setTimeout(resolve, // Failures 4, 5 and 6 lock for 2, 4 and 8 s; one during a lock is not counted. export async function lockOut(request: APIRequestContext, account: Account) { for (let attempt = 0; attempt < 4; attempt++) await failSignIn(request, account); - await pause(2); + await pause(2.5); await failSignIn(request, account); - await pause(4); + await pause(4.5); await failSignIn(request, account); } diff --git a/src/Anichron.UI/e2e/login.spec.ts b/src/Anichron.UI/e2e/login.spec.ts index 3ba81c23..90389949 100644 --- a/src/Anichron.UI/e2e/login.spec.ts +++ b/src/Anichron.UI/e2e/login.spec.ts @@ -42,6 +42,9 @@ test('a user signs in and lands on the signed-in page', async ({ page, request } await expect(page.getByText(signedIn)).toBeVisible(); await expect(page).toHaveURL('/'); await expectAccessible(page); + + await page.reload(); + await expect(page).toHaveURL('/login'); }); test('a wrong password is refused in the user’s language', async ({ page, request }) => { @@ -64,6 +67,7 @@ test('a disabled account is told to ask the administrator', async ({ page, reque 'This account is disabled. Ask your administrator to enable it.', ); await expect(page).toHaveURL('/login'); + await expectAccessible(page); }); test('a locked account counts down, then signs in', async ({ page, request }) => { diff --git a/src/Anichron.UI/src/features/login/LoginPage.test.tsx b/src/Anichron.UI/src/features/login/LoginPage.test.tsx index 274be63c..981e7d66 100644 --- a/src/Anichron.UI/src/features/login/LoginPage.test.tsx +++ b/src/Anichron.UI/src/features/login/LoginPage.test.tsx @@ -45,7 +45,7 @@ describe('the login screen', () => { it('signs in with the trimmed username and lands on the signed-in page', async () => { const sent: LoginRequest[] = []; server.use(loginSucceeds((request) => sent.push(request))); - const { router } = await openLogin(); + const { router, container } = await openLogin(); signIn(' mira ', 'correct horse battery staple'); @@ -53,6 +53,7 @@ describe('the login screen', () => { await screen.findByText("You're signed in. Your photo library is on its way."), ).toBeVisible(); expect(router.state.location.pathname).toBe('/'); + expect(await axeResults(container)).toHaveNoViolations(); expect(sent).toEqual([{ usernameOrEmail: 'mira', password: 'correct horse battery staple' }]); }); diff --git a/src/Anichron.UI/src/features/login/messages.ts b/src/Anichron.UI/src/features/login/messages.ts index 9344db30..49abb6c4 100644 --- a/src/Anichron.UI/src/features/login/messages.ts +++ b/src/Anichron.UI/src/features/login/messages.ts @@ -1,3 +1,5 @@ +import type en from '../../i18n/locales/en.json'; + const keys = new Map([ ['required', 'login.errors.required'], ['invalid_credentials', 'login.errors.invalidCredentials'], @@ -6,13 +8,7 @@ const keys = new Map([ ['rate_limited', 'login.errors.rateLimited'], ]); -export type MessageKey = - | 'login.errors.required' - | 'login.errors.invalidCredentials' - | 'login.errors.accountDisabled' - | 'login.errors.accountLocked' - | 'login.errors.rateLimited' - | 'login.errors.generic'; +export type MessageKey = `login.errors.${keyof typeof en.login.errors}`; export function messageKey(code: string | undefined): MessageKey { return keys.get(code) ?? 'login.errors.generic'; diff --git a/src/Anichron.UI/src/test/handlers/login.ts b/src/Anichron.UI/src/test/handlers/login.ts index 183a9c2b..b0f6b583 100644 --- a/src/Anichron.UI/src/test/handlers/login.ts +++ b/src/Anichron.UI/src/test/handlers/login.ts @@ -24,8 +24,6 @@ export function loginFails(status: number, body: object, headers: Record Date: Fri, 30 Oct 2026 20:36:51 +1300 Subject: [PATCH 3/3] fix(ui): The countdown is not read out every second (#360) The countdown sits beside the alert instead of inside it, so a screen reader announces the error once rather than on every tick. The success flow checks for CSP violations before the reload clears them. Co-Authored-By: Claude Opus 5.5 --- src/Anichron.UI/e2e/login.spec.ts | 5 +++-- src/Anichron.UI/src/features/login/LoginPage.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Anichron.UI/e2e/login.spec.ts b/src/Anichron.UI/e2e/login.spec.ts index 90389949..6f5f114f 100644 --- a/src/Anichron.UI/e2e/login.spec.ts +++ b/src/Anichron.UI/e2e/login.spec.ts @@ -43,6 +43,7 @@ test('a user signs in and lands on the signed-in page', async ({ page, request } await expect(page).toHaveURL('/'); await expectAccessible(page); + expect(await page.evaluate(() => Reflect.get(window, 'cspViolations') as string[])).toEqual([]); await page.reload(); await expect(page).toHaveURL('/login'); }); @@ -79,13 +80,13 @@ test('a locked account counts down, then signs in', async ({ page, request }) => const alert = page.getByRole('alert'); await expect(alert).toContainText('Too many failed attempts. This account is locked for now.'); - await expect(alert).toContainText(/You can try again in \d+ seconds?\./); + await expect(page.getByText(/You can try again in \d+ seconds?\./)).toBeVisible(); const submit = page.getByRole('button', { name: 'Sign in' }); await expect(submit).toBeDisabled(); await expectAccessible(page); await expect(submit).toBeEnabled({ timeout: 10_000 }); - await expect(alert).not.toContainText('You can try again'); + await expect(page.getByText(/You can try again/)).toBeHidden(); await submit.click(); await expect(page.getByText(signedIn)).toBeVisible(); diff --git a/src/Anichron.UI/src/features/login/LoginPage.tsx b/src/Anichron.UI/src/features/login/LoginPage.tsx index c3264931..5ef5840b 100644 --- a/src/Anichron.UI/src/features/login/LoginPage.tsx +++ b/src/Anichron.UI/src/features/login/LoginPage.tsx @@ -35,10 +35,10 @@ export function LoginPage() { function FormError({ error, retryIn }: { error: MessageKey | undefined; retryIn: number }) { const { t } = useTranslation(); return ( -
- {error &&

{t(error)}

} + <> +
{error &&

{t(error)}

}
{retryIn > 0 &&

{t('login.retryIn', { count: retryIn })}

} -
+ ); }