Files
donetick/src/views/Settings/MFASettings.jsx
Mo Tarbin 8c9bf0db1d feat: enhance localization across settings views
- Updated LocalizationSettings to use translation keys for titles and descriptions.
- Refactored MFASettings to utilize translation for error messages, success messages, and UI text.
- Improved NotificationSetting by integrating translation for all user-facing strings.
- Initial Hebrew translation ( testing AI translation ( some of crowdin)
- Adjusted ProfileSettings to use translation for the edit photo title.
- Enhanced SettingsOverview to utilize translation for the early access label.
- Updated SidepanelSettings to implement translation for card names and descriptions.
- Refined StorageSettings to use translation for all storage-related messages and titles.
- Improved ThemeSettings to utilize translation for theme descriptions and titles.
2026-08-11 02:19:40 -04:00

517 lines
16 KiB
JavaScript

import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
import QRCode from 'qrcode'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import {
ConfirmMFA,
DisableMFA,
GetMFAStatus,
SetupMFA,
} from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import SettingsLayout from './SettingsLayout'
const MFASettings = () => {
const { t } = useTranslation('settings')
const [mfaEnabled, setMfaEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [setupModalOpen, setSetupModalOpen] = useState(false)
const [disableModalOpen, setDisableModalOpen] = useState(false)
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false)
const [setupData, setSetupData] = useState(null)
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [disableCode, setDisableCode] = useState('')
const [backupCodes, setBackupCodes] = useState([])
const [setupStep, setSetupStep] = useState(1) // 1: QR Code, 2: Verification, 3: Backup Codes
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
fetchMFAStatus()
}, [])
const fetchMFAStatus = async () => {
try {
setLoading(true)
const response = await GetMFAStatus()
if (response.ok) {
const data = await response.json()
setMfaEnabled(data.mfaEnabled)
}
} catch (error) {
console.error('Error fetching MFA status:', error)
} finally {
setLoading(false)
}
}
const generateQRCode = async url => {
try {
const qrCodeDataUrl = await QRCode.toDataURL(url, {
width: 200,
margin: 2,
})
setQrCodeDataUrl(qrCodeDataUrl)
} catch (error) {
console.error('Error generating QR code:', error)
setError(t('mfa.errors.qrGenerationFailed'))
}
}
const handleSetupMFA = async () => {
try {
setError('')
const response = await SetupMFA()
console.log('MFA Setup Response Status:', response.status)
console.log('MFA Setup Response:', response)
if (response.ok) {
const data = await response.json()
console.log('MFA Setup Data:', data)
// Check for either qrCode (base64 image) or qrCodeUrl (TOTP URL)
if (!data.qrCodeUrl || !data.backupCodes || !data.secret) {
console.error('Missing required MFA data:', {
hasQrCode: !!data.qrCode,
hasQrCodeUrl: !!data.qrCodeUrl,
hasSecret: !!data.secret,
})
setError(t('mfa.errors.invalidResponse'))
return
}
if (data.backupCodes) {
console.log('Backup Codes:', data.backupCodes)
setBackupCodes(data.backupCodes)
}
// If we have a qrCodeUrl, generate the QR code image
if (data.qrCodeUrl) {
await generateQRCode(data.qrCodeUrl)
}
setSetupData(data)
setSetupModalOpen(true)
setSetupStep(1)
} else {
// Handle different error status codes
if (response.status === 404) {
setError(t('mfa.errors.notFound'))
} else if (response.status === 401) {
setError(t('mfa.errors.unauthorized'))
} else if (response.status === 500) {
setError(t('mfa.errors.serverError'))
} else {
const errorData = await response.json().catch(() => ({}))
setError(
errorData.message ||
t('mfa.errors.setupFailed', { status: response.status }),
)
}
}
} catch (error) {
console.error('Error setting up MFA:', error)
setError(t('mfa.errors.networkError'))
}
}
const handleConfirmMFA = async () => {
try {
setError('')
const response = await ConfirmMFA(
setupData.secret,
verificationCode,
setupData.backupCodes,
)
if (response.ok) {
setSetupStep(3)
setMfaEnabled(true)
setSuccess(t('mfa.enabledSuccess'))
} else {
setError(t('mfa.errors.invalidCode'))
}
} catch (error) {
setError(t('mfa.errors.confirmFailed'))
console.error('Error confirming MFA:', error)
}
}
const handleDisableMFA = async () => {
try {
setError('')
const response = await DisableMFA(disableCode)
if (response.ok) {
setMfaEnabled(false)
setDisableModalOpen(false)
setDisableCode('')
setSuccess(t('mfa.disabledSuccess'))
} else {
setError(t('mfa.errors.invalidCode'))
}
} catch (error) {
setError(t('mfa.errors.disableFailed'))
console.error('Error disabling MFA:', error)
}
}
const closeSetupModal = () => {
setSetupModalOpen(false)
setSetupStep(1)
setVerificationCode('')
setSetupData(null)
setQrCodeDataUrl('')
setError('')
}
const closeDisableModal = () => {
setDisableModalOpen(false)
setDisableCode('')
setError('')
}
if (loading) {
return <LoadingComponent />
}
return (
<SettingsLayout title={t('mfa.title')}>
<div className='grid gap-4 py-4' id='mfa'>
<Typography level='body-md'>{t('mfa.description')}</Typography>
{success && (
<Alert color='success' onClose={() => setSuccess('')}>
{success}
</Alert>
)}
{error && (
<Alert color='danger' onClose={() => setError('')}>
{error}
</Alert>
)}
<Card variant='outlined'>
<Box className='flex items-center justify-between'>
<Box className='flex items-center gap-3'>
<Security color='primary' />
<Box>
<Typography level='title-md'>{t('mfa.twoFactor')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{mfaEnabled
? t('mfa.enabledSubtitle')
: t('mfa.disabledSubtitle')}
</Typography>
</Box>
</Box>
<Box className='flex items-center gap-2'>
{mfaEnabled ? (
<Button
color='danger'
variant='outlined'
onClick={() => setDisableModalOpen(true)}
>
{t('mfa.disable')}
</Button>
) : (
<Button
color='primary'
variant='solid'
onClick={handleSetupMFA}
>
{t('mfa.enable')}
</Button>
)}
</Box>
</Box>
</Card>
{/*
{mfaEnabled && (
<Card variant='outlined'>
<Box className='flex items-center justify-between'>
<Box className='flex items-center gap-3'>
<Key color='primary' />
<Box>
<Typography level='title-md'>Backup Codes</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Generate new backup codes in case you lose access to your
authenticator
</Typography>
</Box>
</Box>
<Button
color='neutral'
variant='outlined'
size='sm'
onClick={handleRegenerateBackupCodes}
>
Generate New Codes
</Button>
</Box>
</Card>
)} */}
{/* Setup MFA Modal */}
<AppModal
open={setupModalOpen}
onClose={closeSetupModal}
title={t('mfa.setup.title')}
size='md'
footer={
setupStep === 1 ? (
<ModalActions
secondary={{
label: t('common.cancel'),
onClick: closeSetupModal,
}}
primary={{
label: t('mfa.setup.addedAccount'),
onClick: () => setSetupStep(2),
startDecorator: <Smartphone />,
}}
/>
) : setupStep === 2 ? (
<ModalActions
secondary={{
label: t('mfa.setup.back'),
onClick: () => setSetupStep(1),
}}
primary={{
label: t('mfa.setup.verifyAndEnable'),
onClick: handleConfirmMFA,
disabled: verificationCode.length !== 6,
}}
/>
) : (
<ModalActions
primary={{
label: t('mfa.setup.savedBackupCodes'),
onClick: closeSetupModal,
}}
/>
)
}
>
{setupStep === 1 && setupData && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>{t('mfa.setup.step1Label')}</strong>{' '}
{t('mfa.setup.step1')}
</Typography>
<Box className='flex justify-center rounded bg-white p-4'>
{qrCodeDataUrl || setupData.qrCode ? (
<img
src={
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
alt={t('mfa.setup.qrAlt')}
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
<Alert color='danger'>{t('mfa.setup.qrFailed')}</Alert>
)}
</Box>
<Alert
color='neutral'
variant='soft'
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
<strong>{t('mfa.setup.manualKey')}</strong>
</Typography>
<Typography
level='body-sm'
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
>
{setupData.secret}
</Typography>
</Alert>
</Stack>
)}
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>{t('mfa.setup.step2Label')}</strong>{' '}
{t('mfa.setup.step2')}
</Typography>
<Input
placeholder={t('mfa.setup.codePlaceholder')}
value={verificationCode}
size='lg'
// send on enter:
onKeyDown={e => {
if (e.key === 'Enter' && verificationCode.length === 6) {
handleConfirmMFA()
}
}}
onChange={e => setVerificationCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{error && <Alert color='danger'>{error}</Alert>}
</Stack>
)}
{setupStep === 3 && (
<Stack spacing={3}>
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
{t('mfa.setup.successTitle')}
</Typography>
</Box>
<Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
{t('mfa.setup.backupCodesTitle')}
</Typography>
<Typography level='body-sm'>
{t('mfa.setup.backupCodesDescription')}
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
</Stack>
)}
</AppModal>
{/* Disable MFA Modal */}
<AppModal
open={disableModalOpen}
onClose={closeDisableModal}
title={t('mfa.disableModal.title')}
size='sm'
role='alertdialog'
closeOnBackdrop={false}
footer={
<ModalActions
secondary={{
label: t('common.cancel'),
onClick: closeDisableModal,
}}
primary={{
label: t('mfa.disableModal.confirm'),
color: 'danger',
onClick: handleDisableMFA,
disabled: disableCode.length !== 6,
}}
/>
}
>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
{t('mfa.disableModal.warning')}
</Typography>
</Alert>
<Typography level='body-md'>
{t('mfa.disableModal.prompt')}
</Typography>
<Input
placeholder={t('mfa.setup.codePlaceholder')}
value={disableCode}
size='lg'
onKeyDown={e => {
if (e.key === 'Enter' && disableCode.length === 6) {
handleDisableMFA()
}
}}
onChange={e => setDisableCode(e.target.value)}
sx={{
textAlign: 'center',
fontSize: '1.2em',
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
}}
slotProps={{
input: {
maxLength: 6,
pattern: '[0-9]*',
},
}}
/>
{error && <Alert color='danger'>{error}</Alert>}
</Stack>
</AppModal>
{/* Backup Codes Modal */}
<AppModal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
title={t('mfa.backupCodesModal.title')}
size='sm'
footer={
<ModalActions
primary={{
label: t('mfa.setup.savedBackupCodes'),
onClick: () => setBackupCodesModalOpen(false),
}}
/>
}
>
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
{t('mfa.backupCodesModal.warning')}
</Typography>
</Alert>
<Card variant='outlined' sx={{ p: 2 }}>
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
{backupCodes?.map((code, index) => (
<Typography
key={index}
level='body-sm'
sx={{ fontFamily: 'monospace' }}
>
{code}
</Typography>
))}
</Box>
</Card>
</Stack>
</AppModal>
</div>
</SettingsLayout>
)
}
export default MFASettings