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

@ -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()
})