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

@ -20,6 +20,9 @@ export const routes: RouteObject[] = [
{ path: '/', element: <HomePage /> },
{ path: '/login', lazy: () => import('@pages/public/auth/LoginPage').then(toRoute) },
{ path: '/register', lazy: () => import('@pages/public/auth/RegisterPage').then(toRoute) },
{ path: '/verify', lazy: () => import('@pages/public/auth/VerifyPage').then(toRoute) },
{ path: '/forgot-password', lazy: () => import('@pages/public/auth/ForgotPasswordPage').then(toRoute) },
{ path: '/reset-password', lazy: () => import('@pages/public/auth/ResetPasswordPage').then(toRoute) },
{ path: '/download', lazy: () => import('@pages/download/DownloadRoute').then(toRoute) },
{ path: '/themes', lazy: () => import('@pages/themes/ThemesRoute').then(toRoute) },
{ path: '/u/:id', lazy: () => import('@pages/public/ProfilePage').then(toRoute) },

View file

@ -8,6 +8,7 @@ import { json, mockFetch } from '@test/fetch'
const me = (avatar_url: string | null) => ({
id: '1',
email_verified: true,
email: 'a@b.c',
display_nick: 'Rider',
role: 'user',

View file

@ -0,0 +1,48 @@
import { useState } from 'react'
import { useTranslation } from '@shared/i18n/react'
import { Notice } from '@shared/ui/Notice'
import { resendVerification } from './api'
import { useSession } from './session/useSession'
/**
* The "confirm your email" strip: shown under the top bar for every
* signed-in visitor with an unconfirmed address, because device linking
* (and later addon publishing) stays locked until the email is verified.
*/
export function VerifyEmailBanner() {
const { t } = useTranslation('auth')
const { status, me } = useSession()
const [state, setState] = useState<'idle' | 'busy' | 'sent' | 'failed'>('idle')
if (status !== 'authenticated' || !me || me.email_verified) return null
async function resend() {
setState('busy')
try {
await resendVerification()
setState('sent')
} catch {
setState('failed')
}
}
return (
<div className="mb-4">
<Notice tone="info">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span>{t('banner.text', { email: me.email })}</span>
<button
type="button"
onClick={resend}
disabled={state === 'busy'}
className="rounded-ctl border border-shoal px-2 py-1 text-sm text-frost transition-colors hover:border-ice hover:text-ice disabled:opacity-50"
>
{t('banner.resend')}
</button>
</div>
{state === 'sent' ? <p className="mt-2 text-ice">{t('banner.resent')}</p> : null}
{state === 'failed' ? <p className="mt-2 text-ember">{t('banner.failed')}</p> : null}
</Notice>
</div>
)
}

View file

@ -19,3 +19,19 @@ export async function logout(): Promise<void> {
}
export const fetchMe = () => api.request<Me>('/me')
/** Asks for a password reset email. The answer never reveals whether the address is registered. */
export const forgotPassword = (email: string) =>
api.request('/auth/forgot-password', { method: 'POST', json: { email } })
/** Consumes a reset token and sets the new password. */
export const resetPassword = (token: string, password: string) =>
api.request('/auth/reset-password', { method: 'POST', json: { token, new_password: password } })
/** Consumes a verification token from the email link. */
export const verifyEmail = (token: string) =>
api.request('/auth/verify-email', { method: 'POST', json: { token } })
/** Sends the verification email again for the signed-in account. */
export const resendVerification = () =>
api.request('/auth/resend-verification', { method: 'POST' })

View file

@ -51,4 +51,33 @@ export const authEn = {
nickRequired: 'Pick a nickname.',
nickTooLong: 'No longer than 32 characters.',
},
banner: {
text: 'Confirm your email to link the mod to the account. The message went to {{email}}.',
resend: 'Send the email again',
resent: 'Email sent. Check the spam folder if nothing arrived.',
failed: 'Could not send the email. Try again later.',
},
forgot: {
title: 'Password recovery',
emailLabel: 'Email',
submit: 'Send the email',
sent: 'If that address is registered, the reset email is on its way.',
backToLogin: 'Back to log in',
},
reset: {
title: 'New password',
passwordLabel: 'New password',
passwordHint: 'At least 8 characters.',
submit: 'Change password',
done: 'Password updated. Log in with the new one.',
badToken: 'The link is invalid or expired. Request a new reset.',
goLogin: 'To log in',
},
verify: {
title: 'Email verification',
checking: 'Verifying…',
done: 'Email verified. The mod can now be linked on the Account tab.',
badToken: 'The link is invalid or expired.',
goAccount: 'To the Account tab',
},
} satisfies Translation<typeof authRu>

View file

@ -48,4 +48,33 @@ export const authRu = {
nickRequired: 'Придумай ник.',
nickTooLong: 'Не длиннее 32 символов.',
},
banner: {
text: 'Подтверди почту, чтобы привязать мод к аккаунту. Письмо ушло на {{email}}.',
resend: 'Выслать письмо снова',
resent: 'Письмо отправлено. Загляни в спам, если его нет.',
failed: 'Не получилось отправить письмо. Попробуй ещё раз позже.',
},
forgot: {
title: 'Восстановление пароля',
emailLabel: 'Почта',
submit: 'Отправить письмо',
sent: 'Если эта почта зарегистрирована, письмо со сбросом уже летит.',
backToLogin: 'Вернуться ко входу',
},
reset: {
title: 'Новый пароль',
passwordLabel: 'Новый пароль',
passwordHint: 'Минимум 8 символов.',
submit: 'Сменить пароль',
done: 'Пароль обновлён. Входи с новым паролем.',
badToken: 'Ссылка недействительна или устарела. Запроси сброс ещё раз.',
goLogin: 'Ко входу',
},
verify: {
title: 'Подтверждение почты',
checking: 'Подтверждаем…',
done: 'Почта подтверждена. Мод теперь можно привязать на вкладке «Аккаунт».',
badToken: 'Ссылка недействительна или устарела.',
goAccount: 'На вкладку «Аккаунт»',
},
} as const

View file

@ -6,7 +6,7 @@ import { RegisterForm } from '../forms/RegisterForm'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, email_verified: true, created_at: '2026-09-24T00:00:00Z' }
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
test('wrong password shows a clear error', async () => {

View file

@ -5,7 +5,7 @@ import { routes } from '@app/routes'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, email_verified: true, created_at: '2026-09-24T00:00:00Z' }
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
test('the tab bar switches between login and register', async () => {

View file

@ -5,7 +5,7 @@ import { useSession } from '../session/useSession'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, email_verified: true, created_at: '2026-09-24T00:00:00Z' }
function Whoami() {
const { me } = useSession()

View file

@ -0,0 +1,81 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { routes } from '@app/routes'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
const meVerified = {
id: '1',
email: 'a@b.c',
display_nick: 'Rider',
role: 'user' as const,
avatar_url: null,
email_verified: true,
created_at: '2026-09-24T00:00:00Z',
}
const meUnverified = { ...meVerified, email_verified: false }
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
test('unverified account sees the confirm banner and can resend the email', async () => {
localStorage.setItem('lv_session', '1')
const user = userEvent.setup()
const fetchFn = mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json(meUnverified),
'POST /auth/resend-verification': () => json({ status: 'verification email sent' }, 202),
})
renderApp(routes, '/account')
expect(await screen.findByRole('status')).toHaveTextContent('Подтверди почту')
expect(screen.getByRole('status')).toHaveTextContent('a@b.c')
await user.click(screen.getByRole('button', { name: 'Выслать письмо снова' }))
expect(await screen.findByText('Письмо отправлено. Загляни в спам, если его нет.')).toBeInTheDocument()
// The resend call must carry the bearer so the gateway can resolve the account.
const resendCall = fetchFn.mock.calls.find(
([input, init]) => String(input).endsWith('/auth/resend-verification') && (init as RequestInit).method === 'POST',
)
expect(resendCall).toBeDefined()
const headers = (resendCall![1] as RequestInit).headers as Headers
expect(headers.get('authorization')).toBe('Bearer t')
})
test('verified account sees no confirm banner', async () => {
localStorage.setItem('lv_session', '1')
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json(meVerified),
})
renderApp(routes, '/account')
// The nick renders on the account page (and in the top bar): the query
// answered means the banner logic had its chance to show or stay hidden.
await screen.findAllByText('Rider')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
test('the email link page verifies the token', async () => {
mockFetch({ 'POST /auth/verify-email': () => json({ status: 'email verified' }) })
renderApp(routes, '/verify?token=lvev_ok')
expect(await screen.findByText('Почта подтверждена. Мод теперь можно привязать на вкладке «Аккаунт».')).toBeInTheDocument()
})
test('a missing token answers with the invalid-link message', async () => {
mockFetch(anon)
renderApp(routes, '/verify')
expect(await screen.findByText('Ссылка недействительна или устарела.')).toBeInTheDocument()
})
test('forgot-password answers the same for any email', async () => {
const user = userEvent.setup()
mockFetch({
...anon,
'POST /auth/forgot-password': () => json({ status: 'reset email sent if the account exists' }, 202),
})
renderApp(routes, '/forgot-password')
await user.type(await screen.findByLabelText('Почта'), 'who-knows@example.com')
await user.click(screen.getByRole('button', { name: 'Отправить письмо' }))
expect(
await screen.findByText('Если эта почта зарегистрирована, письмо со сбросом уже летит.'),
).toBeInTheDocument()
})

View file

@ -0,0 +1,63 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { forgotPassword } from '@features/auth/api'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'
import { TextField } from '@shared/ui/text-field/TextField'
/** /forgot-password: asks for a reset email. The answer is the same whether or not the address exists. */
export default function ForgotPasswordPage() {
const { t } = useTranslation('auth')
const { t: tc } = useTranslation('common')
const [email, setEmail] = useState('')
const [error, setError] = useState<string>()
const [sent, setSent] = useState(false)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(undefined)
try {
await forgotPassword(email.trim())
setSent(true)
} catch (err) {
setError(describeError(tc, err))
} finally {
setBusy(false)
}
}
return (
<section className="mx-auto mt-16 w-full max-w-md">
<h1 className="mb-4 text-2xl font-semibold text-frost">{t('forgot.title')}</h1>
{sent ? (
<>
<Notice tone="success">{t('forgot.sent')}</Notice>
<Link to="/login" className="mt-4 inline-block text-ice hover:underline">
{t('forgot.backToLogin')}
</Link>
</>
) : (
<form onSubmit={submit} className="flex w-full flex-col gap-4" noValidate>
<TextField
label={t('forgot.emailLabel')}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}>
{t('forgot.submit')}
</Button>
<Link to="/login" className="text-sm text-ice hover:underline">
{t('forgot.backToLogin')}
</Link>
</form>
)}
</section>
)
}

View file

@ -0,0 +1,73 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from '@shared/i18n/react'
import { Link, useNavigate, useSearchParams } from 'react-router'
import { resetPassword } from '@features/auth/api'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'
import { TextField } from '@shared/ui/text-field/TextField'
/** /reset-password?token=...: consumes the email link and sets the new password. */
export default function ResetPasswordPage() {
const { t } = useTranslation('auth')
const { t: tc } = useTranslation('common')
const navigate = useNavigate()
const [params] = useSearchParams()
const token = params.get('token') ?? ''
const [password, setPassword] = useState('')
const [error, setError] = useState<string>()
const [badToken, setBadToken] = useState(false)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(undefined)
try {
await resetPassword(token, password)
navigate('/login', { replace: true })
} catch (err) {
// 400 covers unknown/expired/used links: same message, next step is a
// fresh reset request.
if (err instanceof Error && 'status' in err && (err as { status: number }).status === 400) {
setBadToken(true)
} else {
setError(describeError(tc, err))
}
} finally {
setBusy(false)
}
}
if (badToken) {
return (
<section className="mx-auto mt-16 w-full max-w-md">
<h1 className="mb-4 text-2xl font-semibold text-frost">{t('reset.title')}</h1>
<Notice tone="error">{t('reset.badToken')}</Notice>
<Link to="/forgot-password" className="mt-4 inline-block text-ice hover:underline">
{t('reset.goLogin')}
</Link>
</section>
)
}
return (
<section className="mx-auto mt-16 w-full max-w-md">
<h1 className="mb-4 text-2xl font-semibold text-frost">{t('reset.title')}</h1>
<form onSubmit={submit} className="flex w-full flex-col gap-4" noValidate>
<TextField
label={t('reset.passwordLabel')}
hint={t('reset.passwordHint')}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}>
{t('reset.submit')}
</Button>
</form>
</section>
)
}

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { useTranslation } from '@shared/i18n/react'
import { Link, useSearchParams } from 'react-router'
import { verifyEmail } from '@features/auth/api'
import { Notice } from '@shared/ui/Notice'
type Outcome = 'checking' | 'done' | 'bad'
/** Landing for the link in the verification email: /verify?token=... */
export default function VerifyPage() {
const { t } = useTranslation('auth')
const [params] = useSearchParams()
const token = params.get('token')
// A missing token is already the final answer; a present one starts as
// "checking" and resolves from the endpoint call below.
const [outcome, setOutcome] = useState<Outcome>(token ? 'checking' : 'bad')
useEffect(() => {
if (!token) return
let cancelled = false
verifyEmail(token)
.then(() => {
if (!cancelled) setOutcome('done')
})
.catch(() => {
if (!cancelled) setOutcome('bad')
})
return () => {
cancelled = true
}
}, [token])
return (
<section className="mx-auto mt-16 w-full max-w-md">
<h1 className="mb-4 text-2xl font-semibold text-frost">{t('verify.title')}</h1>
{outcome === 'checking' ? <p className="text-frost">{t('verify.checking')}</p> : null}
{outcome === 'done' ? (
<>
<Notice tone="success">{t('verify.done')}</Notice>
<Link to="/account" className="mt-4 inline-block text-ice hover:underline">
{t('verify.goAccount')}
</Link>
</>
) : null}
{outcome === 'bad' ? <Notice tone="error">{t('verify.badToken')}</Notice> : null}
</section>
)
}

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
}