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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue