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
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
63
frontend/src/pages/public/auth/ForgotPasswordPage.tsx
Normal file
63
frontend/src/pages/public/auth/ForgotPasswordPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
frontend/src/pages/public/auth/ResetPasswordPage.tsx
Normal file
73
frontend/src/pages/public/auth/ResetPasswordPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/pages/public/auth/VerifyPage.tsx
Normal file
48
frontend/src/pages/public/auth/VerifyPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue