Every user-facing string under src/views/Authorization/ moves to i18next.
English only — no translations in this PR, so it is language-agnostic and
reviewable as a pure refactor.
Covered:
- LoginView — primary/sub-account tabs, credential form, social and
Authentik buttons, welcome-back state, every auth error toast
- Signup — account creation form and each field-validation message
- ForgotPasswordView / UpdatePasswordView — reset flow and its toasts
- Authenticating — the OAuth landing screen, including its MFA branches
- MFAVerificationModal — code entry, backup codes, error states
- AuthFields / LoginSettings — shared field labels and server settings
`auth` is registered in src/i18n/config.js; public/locales/en/auth.json
holds the 103 keys. The Crowdin config picks up /public/locales/en/*.json
by glob, so the namespace flows into the pipeline with no change to
crowdin.yml.
No behaviour change: every t() value is the string that rendered before,
character for character — checked mechanically against this branch's base.
The e2e suite selects auth controls by visible text ('Create account',
'Username must be at least 4 characters'), so a green run is the proof.
Two fixes fell out of the extraction:
- AuthPasswordField had label='Password' as a prop default, so neither
LoginView nor Signup passed one. A default cannot be translated at module
scope, so the label moves to the call sites and both now pass it.
- AuthDivider defaulted children to 'or' for the same reason; it now falls
back to t('or').
181 lines
4.9 KiB
JavaScript
181 lines
4.9 KiB
JavaScript
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
|
import { useState } from 'react'
|
|
|
|
import ModalActions from '../../components/common/ModalActions'
|
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
|
import { VerifyMFA } from '../../utils/Fetcher'
|
|
import { authInputSx } from './authStyles'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
const MFAVerificationModal = ({
|
|
open,
|
|
onClose,
|
|
sessionToken,
|
|
onSuccess,
|
|
onError,
|
|
}) => {
|
|
const { t } = useTranslation('auth')
|
|
const [verificationCode, setVerificationCode] = useState('')
|
|
const [isBackupCode, setIsBackupCode] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const { ResponsiveModal } = useResponsiveModal()
|
|
|
|
const handleVerify = async () => {
|
|
if (!verificationCode.trim()) {
|
|
setError(t('mfaModal.codeRequired'))
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const response = await VerifyMFA(sessionToken, verificationCode)
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
onSuccess(data)
|
|
} else {
|
|
const errorData = await response.json()
|
|
const message =
|
|
errorData.message || t('mfaModal.invalidCode')
|
|
setError(message)
|
|
onError?.(message)
|
|
}
|
|
} catch (error) {
|
|
// A wrong code is shown inline; a failed request is escalated to the
|
|
// caller so it can surface a toast instead of looking like a bad code.
|
|
const message = t('mfaModal.verifyFailed')
|
|
setError(message)
|
|
onError?.(message)
|
|
console.error('MFA verification error:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
setVerificationCode('')
|
|
setIsBackupCode(false)
|
|
setError('')
|
|
setLoading(false)
|
|
onClose()
|
|
}
|
|
|
|
const handleKeyDown = e => {
|
|
if (e.key === 'Enter' && !loading) {
|
|
e.preventDefault()
|
|
handleVerify()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ResponsiveModal
|
|
open={open}
|
|
onClose={handleClose}
|
|
size='md'
|
|
title={t('mfaModal.title')}
|
|
description={
|
|
isBackupCode
|
|
? t('mfaModal.backupHint')
|
|
: t('mfaModal.codeHint')
|
|
}
|
|
closeOnBackdrop={!loading}
|
|
closeOnEscape={!loading}
|
|
footer={
|
|
<ModalActions
|
|
secondary={{
|
|
label: t('common:cancel'),
|
|
onClick: handleClose,
|
|
disabled: loading,
|
|
}}
|
|
primary={{
|
|
label: t('mfaModal.verify'),
|
|
onClick: handleVerify,
|
|
loading,
|
|
disabled: !verificationCode.trim(),
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<Stack spacing={2.5}>
|
|
<Box>
|
|
<Typography
|
|
component='label'
|
|
htmlFor='mfa-code'
|
|
level='body-sm'
|
|
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
|
|
>
|
|
{isBackupCode ? t('mfaModal.backupLabel') : t('mfaModal.codeLabel')}
|
|
</Typography>
|
|
<Input
|
|
id='mfa-code'
|
|
size='lg'
|
|
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'}
|
|
value={verificationCode}
|
|
onChange={e => setVerificationCode(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
error={Boolean(error)}
|
|
autoFocus
|
|
sx={{
|
|
...authInputSx,
|
|
// Targets the inner <input>; styling the root leaves the text
|
|
// itself unaligned.
|
|
'& input': {
|
|
textAlign: 'center',
|
|
letterSpacing: isBackupCode ? 'normal' : '0.4em',
|
|
fontVariantNumeric: 'tabular-nums',
|
|
fontSize: '1.125rem',
|
|
},
|
|
}}
|
|
slotProps={{
|
|
input: {
|
|
maxLength: isBackupCode ? 50 : 6,
|
|
inputMode: isBackupCode ? 'text' : 'numeric',
|
|
pattern: isBackupCode ? undefined : '[0-9]*',
|
|
autoComplete: isBackupCode ? 'off' : 'one-time-code',
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{error && (
|
|
<Alert color='danger' variant='soft' sx={{ borderRadius: '12px' }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box sx={{ textAlign: 'center' }}>
|
|
<Link
|
|
component='button'
|
|
type='button'
|
|
level='body-sm'
|
|
underline='hover'
|
|
onClick={() => {
|
|
setIsBackupCode(!isBackupCode)
|
|
setVerificationCode('')
|
|
setError('')
|
|
}}
|
|
>
|
|
{isBackupCode
|
|
? t('mfaModal.useAuthenticator')
|
|
: t('mfaModal.useBackup')}
|
|
</Link>
|
|
</Box>
|
|
|
|
{isBackupCode && (
|
|
<Typography
|
|
level='body-xs'
|
|
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
|
>
|
|
{t('mfaModal.backupOnce')}
|
|
</Typography>
|
|
)}
|
|
</Stack>
|
|
</ResponsiveModal>
|
|
)
|
|
}
|
|
|
|
export default MFAVerificationModal
|