feat(accounts): outgoing mail over SMTP (Resend), email verification, fail-closed password reset

- mail/ module (lettre, ru/en templates), links built only from PUBLIC_BASE_URL
- migration 0006: accounts.email_verified_at, shared email_tokens table (verify + reset), existing accounts marked verified
- reset mailer no longer logs tokens; RESET_MAIL_MODE=log is dev-only and refused with COOKIE_SECURE=true; Disabled by default answers 503
- forgot-password and resend-verification do their work in a background task (no timing oracle)
- device linking requires a verified email; email_verified exposed via /me and gRPC
- gateway rate limits, SMTP_* in compose and .env.example
- frontend: verify, forgot-password, reset-password pages, verify banner, ru/en strings
This commit is contained in:
loki5512344 2026-10-09 21:08:33 +02:00
parent 12ce6216b6
commit c57f851a8b
Signed by: boba
GPG key ID: 253067914055423B
48 changed files with 1810 additions and 120 deletions

View file

@ -71,7 +71,12 @@ export function createApiClient(
headers.set('content-type', 'application/json')
body = JSON.stringify(options.json)
}
if (accessToken && !path.startsWith('/auth/')) headers.set('authorization', `Bearer ${accessToken}`)
// /auth/* stays bearer-free for login/register/refresh/logout, except the
// signed-in resend-verification call, which the gateway resolves into an
// account identity.
const needsBearer =
accessToken && (!path.startsWith('/auth/') || path === '/auth/resend-verification')
if (needsBearer) headers.set('authorization', `Bearer ${accessToken}`)
return fetchImpl(`${baseUrl}${path}`, {
method: options.method ?? 'GET',
headers,

View file

@ -1,5 +1,6 @@
import { useTranslation } from '@shared/i18n/react'
import { Outlet } from 'react-router'
import { VerifyEmailBanner } from '@features/auth/VerifyEmailBanner'
import { KonamiEffect } from '../easter/KonamiEffect'
import { Footer } from './Footer'
import { TopBar } from './topbar/TopBar'
@ -17,6 +18,7 @@ export function AppShell() {
<KonamiEffect />
<TopBar />
<main id="main" className="mx-auto flex w-full max-w-[1120px] flex-1 flex-col px-4 pt-(--topbar-h) sm:px-6">
<VerifyEmailBanner />
<Outlet />
</main>
<Footer />

View file

@ -30,7 +30,8 @@ test('a guest never calls /auth/refresh (no 401 in the console)', async () => {
test('signed-in visitor sees their nick instead of login', async () => {
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json({ id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '' }),
'GET /me': () => json({ id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null,
email_verified: true, created_at: '' }),
})
renderApp(routes, '/')
expect(await screen.findByRole('link', { name: /Rider/ })).toHaveAttribute('href', '/account')

View file

@ -4,5 +4,7 @@ export type Me = {
display_nick: string
role: 'user' | 'admin'
avatar_url: string | null
/** False until the address is confirmed; drives the verify-email banner and device linking. */
email_verified: boolean
created_at: string
}