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:
parent
12ce6216b6
commit
c57f851a8b
48 changed files with 1810 additions and 120 deletions
|
|
@ -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',
|
||||
|
|
|
|||
48
frontend/src/features/auth/VerifyEmailBanner.tsx
Normal file
48
frontend/src/features/auth/VerifyEmailBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
81
frontend/src/features/auth/tests/verify.test.tsx
Normal file
81
frontend/src/features/auth/tests/verify.test.tsx
Normal 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()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue