Merge pull request #208 from everysingletear/i18n/auth-v2

i18n: extract Authorization screens into a new `auth` namespace (Part of #145)
This commit is contained in:
Mohamad Tarbin
2026-08-13 19:31:56 -04:00
committed by GitHub
10 changed files with 283 additions and 151 deletions

111
public/locales/en/auth.json Normal file
View File

@@ -0,0 +1,111 @@
{
"validationError": "Validation Error",
"primaryUsernameRequired": "Primary username is required for sub account login",
"subNameRequired": "Sub account name is required for sub account login",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"loginFailed": "Login Failed",
"genericError": "An error occurred, please try again",
"providerLoginFailed": "{{provider}} Login Failed",
"providerLoginFailedMsg": "Couldn't log in with {{provider}}, please try again",
"providerLoginError": "{{provider}} Login Error",
"networkError": "Network error occurred, please try again",
"mfaFailed": "Two-Factor Authentication Failed",
"oauthError": "OAuth Error",
"oauthBrowserFailed": "Failed to open authentication browser",
"primaryAccount": "Primary Account",
"subAccount": "Sub Account",
"username": "Username",
"primaryUsernamePlaceholder": "Enter primary account username",
"subNamePlaceholder": "Enter sub account name",
"forgotPassword": "Forgot password?",
"or": "or",
"email": "Email",
"displayNamePlaceholder": "How others see your name",
"signupFailed": "Signup Failed",
"signupDisabled": "Signup disabled, please contact admin",
"usernameMinLength": "Username must be at least 4 characters",
"invalidEmail": "Invalid email address",
"passwordLength": "Password must be between 8 and 64 characters",
"displayNameRequired": "Display name is required",
"displayNameChars": "Display name can only contain letters and numbers",
"resetEmailSent": "Reset Email Sent",
"resetEmailSentMsg": "Check your email for password reset instructions",
"resetFailed": "Reset Failed",
"resetFailedMsg": "Failed to send reset email, please try again later",
"emailPlaceholder": "you@example.com",
"passwordUpdated": "Password Updated",
"passwordUpdatedMsg": "Your password has been updated successfully. Redirecting to login...",
"passwordUpdateFailed": "Password Update Failed",
"passwordUpdateFailedMsg": "Failed to update password, please try again later",
"savePassword": "Save password",
"server": {
"url": "Server URL",
"dnsFailed": "Hostname could not be resolved. Check the URL for typos or verify DNS.",
"unreachable": "Unable to reach the server. Check the URL, port, and network connection.",
"invalidUrl": "Invalid URL format. Include the protocol (http:// or https://) and port if needed."
},
"mfaModal": {
"title": "Two-factor authentication",
"codeRequired": "Please enter a verification code",
"verifyFailed": "Failed to verify code. Please try again.",
"backupHint": "Enter one of the backup codes you saved when setting up two-factor authentication.",
"codeHint": "Enter the 6-digit code from your authenticator app.",
"verify": "Verify & Sign In",
"backupLabel": "Backup code",
"codeLabel": "Verification code",
"backupPlaceholder": "Enter backup code",
"useAuthenticator": "Use authenticator app instead",
"useBackup": "Use a backup code instead",
"backupOnce": "Each backup code can only be used once.",
"invalidCode": "Invalid verification code. Please try again."
},
"appleLoginFailed": "Apple Login Failed",
"googleLoginFailed": "Google Login Failed",
"createOne": "Create one",
"primaryAccountUsernameLabel": "Primary account username",
"subAccountNameLabel": "Sub account name",
"serverSettings": "Server settings",
"useDifferentAccount": "Use a different account",
"yourUsername": "Your username",
"backToSignIn": "Back to sign in",
"confirmNewPassword": "Confirm new password",
"newPassword": "New password",
"reenterPassword": "Re-enter your password",
"requestNewLink": "Request a new link",
"setNewPassword": "Set a new password",
"linkInvalid": "This link is not valid",
"createAccountButton": "Create account",
"createYourAccount": "Create your account",
"displayNameLabel": "Display name",
"usernameHint": "lowercase letters, numbers, dot and dash",
"termsShort": "By creating an account you agree to our Terms of Service and Privacy",
"checkYourEmail": "Check your email",
"resetYourPassword": "Reset your password",
"serverChangeWarning": "Changing the server clears locally cached data on this device.",
"serverConnected": "Connected. Taking you to sign in...",
"passwordLabel": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"loginPasswordPlaceholder": "Enter your password",
"signupPasswordPlaceholder": "At least 8 characters",
"signupPasswordHelper": "Use 8 to 64 characters.",
"orContinueWith": "or continue with",
"signupSignInFailed": "Your account was created, but signing in failed. Please sign in.",
"authenticating": {
"signingIn": "Signing you in",
"signingInSub": "This will only take a moment.",
"unknownProvider": "Unknown sign-in provider",
"contactSupport": "Please contact support.",
"signInFailed": "Sign-in failed",
"mfaCancelled": "Two-factor authentication was cancelled.",
"requestNotVerified": "The sign-in request could not be verified.",
"tryAgain": "Please try again.",
"mfaSessionMissing": "The MFA session is missing. Please try again.",
"twoFactor": "Two-factor authentication",
"verifyToContinue": "Verify your login to continue.",
"noToken": "No valid authentication token was returned.",
"backToSignIn": "Back to sign in",
"mfaFailed": "Two-factor authentication failed. Please try again."
}
}

View File

@@ -22,7 +22,7 @@ i18n
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
ns: ['common', 'settings', 'chores'],
ns: ['common', 'settings', 'chores', 'auth'],
defaultNS: 'common',
detection: {

View File

@@ -12,6 +12,7 @@ import {
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { authButtonSx, authInputSx } from './authStyles'
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
@@ -40,12 +41,13 @@ export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
)
export const AuthPasswordField = ({
label = 'Password',
label,
error,
helper,
sx,
...inputProps
}) => {
const { t } = useTranslation('auth')
const [visible, setVisible] = useState(false)
return (
@@ -60,7 +62,7 @@ export const AuthPasswordField = ({
color='neutral'
size='sm'
tabIndex={-1}
aria-label={visible ? 'Hide password' : 'Show password'}
aria-label={visible ? t('hidePassword') : t('showPassword')}
onClick={() => setVisible(v => !v)}
sx={{ borderRadius: '8px' }}
>
@@ -110,27 +112,31 @@ export const SocialButton = ({ icon, children, sx, ...props }) => (
</Button>
)
export const AuthDivider = ({ children = 'or' }) => (
<Box
role='separator'
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
my: 2.5,
'&::before, &::after': {
content: '""',
flex: 1,
height: '1px',
bgcolor: 'divider',
},
}}
>
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
{children}
</Typography>
</Box>
)
export const AuthDivider = ({ children }) => {
const { t } = useTranslation('auth')
return (
<Box
role='separator'
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
my: 2.5,
'&::before, &::after': {
content: '""',
flex: 1,
height: '1px',
bgcolor: 'divider',
},
}}
>
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
{children ?? t('or')}
</Typography>
</Box>
)
}
export const LegalLinks = () => (
<Typography

View File

@@ -10,16 +10,18 @@ import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import { useTranslation } from 'react-i18next'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import MFAVerificationModal from './MFAVerificationModal'
const AuthenticationLoading = () => {
const { t } = useTranslation('auth')
const { refetch: refetchUserProfile } = useUserProfile()
const Navigate = useNavigate()
const hasCalledHandleOAuth2 = useRef(false)
const [message, setMessage] = useState('Signing you in')
const [subMessage, setSubMessage] = useState('This will only take a moment.')
const [message, setMessage] = useState(t('authenticating.signingIn'))
const [subMessage, setSubMessage] = useState(t('authenticating.signingInSub'))
const [status, setStatus] = useState('pending')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
@@ -31,8 +33,8 @@ const AuthenticationLoading = () => {
// suppress a genuine session expiry later on.
handleOAuth2().finally(endOAuthExchange)
} else if (provider !== 'oauth2') {
setMessage('Unknown sign-in provider')
setSubMessage('Please contact support.')
setMessage(t('authenticating.unknownProvider'))
setSubMessage(t('authenticating.contactSupport'))
setStatus('error')
}
return endOAuthExchange
@@ -71,8 +73,8 @@ const AuthenticationLoading = () => {
const handleMFAClose = () => {
setMfaModalOpen(false)
setMfaSessionToken('')
setMessage('Sign-in failed')
setSubMessage('Two-factor authentication was cancelled.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.mfaCancelled'))
setStatus('error')
}
@@ -85,8 +87,8 @@ const AuthenticationLoading = () => {
const storedState = localStorage.getItem('authState')
if (returnedState !== storedState) {
setMessage('Sign-in failed')
setSubMessage('The sign-in request could not be verified.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.requestNotVerified'))
setStatus('error')
return
}
@@ -112,8 +114,8 @@ const AuthenticationLoading = () => {
if (!response.ok) {
console.error('Authentication failed')
setMessage('Sign-in failed')
setSubMessage('Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.tryAgain'))
setStatus('error')
return
}
@@ -122,22 +124,22 @@ const AuthenticationLoading = () => {
if (data.mfaRequired) {
if (!data.sessionToken) {
setMessage('Sign-in failed')
setSubMessage('The MFA session is missing. Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.mfaSessionMissing'))
setStatus('error')
return
}
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
setMessage('Two-factor authentication')
setSubMessage('Verify your login to continue.')
setMessage(t('authenticating.twoFactor'))
setSubMessage(t('authenticating.verifyToContinue'))
return
}
if (!data.token && !data.access_token) {
setMessage('Sign-in failed')
setSubMessage('No valid authentication token was returned.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.noToken'))
setStatus('error')
return
}
@@ -158,8 +160,8 @@ const AuthenticationLoading = () => {
}
} catch (error) {
console.error('Authentication request failed', error)
setMessage('Sign-in failed')
setSubMessage('Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.tryAgain'))
setStatus('error')
}
}
@@ -188,7 +190,7 @@ const AuthenticationLoading = () => {
fullWidth
sx={authButtonSx}
>
Back to sign in
{t('authenticating.backToSignIn')}
</Button>
)}
</Box>
@@ -199,8 +201,8 @@ const AuthenticationLoading = () => {
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Sign-in failed')
setSubMessage('Two-factor authentication failed. Please try again.')
setMessage(t('authenticating.signInFailed'))
setSubMessage(t('authenticating.mfaFailed'))
}}
/>
</AuthShell>

View File

@@ -1,6 +1,7 @@
import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined'
import { Box, Button, Link, Typography } from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher'
@@ -12,6 +13,7 @@ const isInvalidEmail = email =>
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
const ForgotPasswordView = () => {
const { t } = useTranslation('auth')
const navigate = useNavigate()
const [resetStatusOk, setResetStatusOk] = useState(null)
const [email, setEmail] = useState('')
@@ -40,21 +42,21 @@ const ForgotPasswordView = () => {
setResetStatusOk(true)
showNotification({
type: 'success',
title: 'Reset Email Sent',
message: 'Check your email for password reset instructions',
title: t('resetEmailSent'),
message: t('resetEmailSentMsg'),
})
} else {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
title: t('resetFailed'),
message: t('resetFailedMsg'),
})
}
} catch (error) {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
title: t('resetFailed'),
message: t('resetFailedMsg'),
})
} finally {
setIsSubmitting(false)
@@ -79,7 +81,7 @@ const ForgotPasswordView = () => {
if (resetStatusOk !== null) {
return (
<AuthShell
title='Check your email'
title={t('checkYourEmail')}
subtitle={`If an account exists for ${email}, we've sent instructions for resetting your password.`}
footer={<LegalLinks />}
logoSize={0}
@@ -101,7 +103,7 @@ const ForgotPasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Back to sign in
{t('backToSignIn')}
</Button>
</Box>
</AuthShell>
@@ -110,7 +112,7 @@ const ForgotPasswordView = () => {
return (
<AuthShell
title='Reset your password'
title={t('resetYourPassword')}
subtitle="Enter your email and we'll send you a link to get back into your account."
footer={<LegalLinks />}
logoSize={0}
@@ -126,7 +128,7 @@ const ForgotPasswordView = () => {
name='email'
type='email'
autoComplete='email'
placeholder='you@example.com'
placeholder={t('emailPlaceholder')}
autoFocus
value={email}
error={emailError}
@@ -152,7 +154,7 @@ const ForgotPasswordView = () => {
underline='hover'
onClick={() => navigate('/login')}
>
Back to sign in
{t('backToSignIn')}
</Link>
</Typography>
</AuthShell>

View File

@@ -12,10 +12,12 @@ import { offlineDB } from '../../utils/OfflineDB'
import { AuthSubmitButton, AuthTextField } from './AuthFields'
import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
import { useTranslation } from 'react-i18next'
const CONNECTION_TIMEOUT_MS = 8000
const LoginSettings = () => {
const { t } = useTranslation('auth')
const Navigate = useNavigate()
const { refetch: refetchResource } = useResource()
const [serverURL, setServerURL] = React.useState('')
@@ -105,7 +107,7 @@ const LoginSettings = () => {
return {
ok: false,
message:
'Hostname could not be resolved. Check the URL for typos or verify DNS.',
t('server.dnsFailed'),
}
}
return {
@@ -118,7 +120,7 @@ const LoginSettings = () => {
return {
ok: false,
message:
'Unable to reach the server. Check the URL, port, and network connection.',
t('server.unreachable'),
}
}
@@ -126,7 +128,7 @@ const LoginSettings = () => {
return {
ok: false,
message:
'Unable to reach the server. Check the URL, port, and network connection.',
t('server.unreachable'),
}
}
}
@@ -144,7 +146,7 @@ const LoginSettings = () => {
if (!isValidURL(trimmedURL)) {
setStatus('error')
setErrorMessage(
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
t('server.invalidUrl'),
)
return
}
@@ -187,7 +189,7 @@ const LoginSettings = () => {
return (
<AuthShell
title='Server settings'
title={t('serverSettings')}
subtitle='Point the app at your own self-hosted Donetick server.'
>
<Box
@@ -196,7 +198,7 @@ const LoginSettings = () => {
sx={{ display: 'flex', flexDirection: 'column' }}
>
<AuthTextField
label='Server URL'
label={t('server.url')}
id='serverURL'
name='serverURL'
inputMode='url'
@@ -243,7 +245,7 @@ const LoginSettings = () => {
startDecorator={<CheckCircleOutlineIcon />}
sx={{ mt: 2, borderRadius: '12px' }}
>
Connected. Taking you to sign in...
{t('serverConnected')}
</Alert>
)}
@@ -290,7 +292,7 @@ const LoginSettings = () => {
level='body-xs'
sx={{ mt: 2.5, textAlign: 'center', color: 'text.secondary' }}
>
Changing the server clears locally cached data on this device.
{t('serverChangeWarning')}
</Typography>
</AuthShell>
)

View File

@@ -10,6 +10,7 @@ import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { LoginSocialGoogle } from 'reactjs-social-login'
@@ -84,6 +85,7 @@ const SegmentedControl = ({ value, onChange, options }) => (
)
const LoginView = () => {
const { t } = useTranslation('auth')
// Use React Query client directly to invalidate the user profile query
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
@@ -158,23 +160,23 @@ const LoginView = () => {
if (loginType === 'sub') {
if (!parentUsername.trim()) {
showError({
title: 'Validation Error',
message: 'Primary username is required for sub account login',
title: t('validationError'),
message: t('primaryUsernameRequired'),
})
return
}
if (!childName.trim()) {
showError({
title: 'Validation Error',
message: 'Sub account name is required for sub account login',
title: t('validationError'),
message: t('subNameRequired'),
})
return
}
} else {
if (!username.trim()) {
showError({
title: 'Validation Error',
message: 'Username is required',
title: t('validationError'),
message: t('usernameRequired'),
})
return
}
@@ -182,8 +184,8 @@ const LoginView = () => {
if (!password) {
showError({
title: 'Validation Error',
message: 'Password is required',
title: t('validationError'),
message: t('passwordRequired'),
})
return
}
@@ -200,7 +202,7 @@ const LoginView = () => {
result = await authLogin({ username: actualUsername, password })
} catch (error) {
showError({
title: 'Login Failed',
title: t('loginFailed'),
message: error?.message || 'An error occurred, please try again',
})
return
@@ -227,8 +229,8 @@ const LoginView = () => {
}
} else {
showError({
title: 'Login Failed',
message: result.error || 'An error occurred, please try again',
title: t('loginFailed'),
message: result.error || t('genericError'),
})
}
}
@@ -298,15 +300,15 @@ const LoginView = () => {
} else {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Failed`,
message: `Couldn't log in with ${providerName}, please try again`,
title: t('providerLoginFailed', { provider: providerName }),
message: t('providerLoginFailedMsg', { provider: providerName }),
})
}
} catch (error) {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Error`,
message: 'Network error occurred, please try again',
title: t('providerLoginError', { provider: providerName }),
message: t('networkError'),
})
}
}
@@ -350,7 +352,7 @@ const LoginView = () => {
const handleMFAError = errorMessage => {
showError({
title: 'Two-Factor Authentication Failed',
title: t('mfaFailed'),
message: errorMessage,
})
}
@@ -398,8 +400,8 @@ const LoginView = () => {
} catch (error) {
console.error('Failed to open OAuth browser:', error)
showError({
title: 'OAuth Error',
message: 'Failed to open authentication browser',
title: t('oauthError'),
message: t('oauthBrowserFailed'),
})
}
} else {
@@ -439,7 +441,7 @@ const LoginView = () => {
<IconButton
variant='plain'
color='neutral'
aria-label='Server settings'
aria-label={t('serverSettings')}
onClick={() => Navigate('/login/settings')}
>
<SettingsOutlined />
@@ -465,7 +467,7 @@ const LoginView = () => {
<Typography level='title-md'>{displayName}</Typography>
{getUserDisplayInfo(userProfile).userType === 'child' && (
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
Sub Account
{t('subAccount')}
</Typography>
)}
</Box>
@@ -486,7 +488,7 @@ const LoginView = () => {
sx={authButtonSx}
onClick={() => apiClient.handleLogout()}
>
Use a different account
{t('useDifferentAccount')}
</Button>
</Box>
) : (
@@ -499,19 +501,19 @@ const LoginView = () => {
value={loginType}
onChange={handleLoginModeChange}
options={[
{ value: 'primary', label: 'Primary Account' },
{ value: 'sub', label: 'Sub Account' },
{ value: 'primary', label: t('primaryAccount') },
{ value: 'sub', label: t('subAccount') },
]}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{loginType === 'primary' ? (
<AuthTextField
label='Username'
label={t('username')}
id='username'
name='username'
autoComplete='username'
placeholder='Your username'
placeholder={t('yourUsername')}
autoFocus
value={username}
onChange={e => setUsername(e.target.value)}
@@ -519,20 +521,20 @@ const LoginView = () => {
) : (
<>
<AuthTextField
label='Primary account username'
label={t('primaryAccountUsernameLabel')}
id='parentUsername'
name='parentUsername'
autoComplete='username'
placeholder='Enter primary account username'
placeholder={t('primaryUsernamePlaceholder')}
autoFocus
value={parentUsername}
onChange={e => setParentUsername(e.target.value)}
/>
<AuthTextField
label='Sub account name'
label={t('subAccountNameLabel')}
id='childName'
name='childName'
placeholder='Enter sub account name'
placeholder={t('subNamePlaceholder')}
value={childName}
onChange={e => setChildName(e.target.value)}
/>
@@ -544,7 +546,8 @@ const LoginView = () => {
id='password'
name='password'
autoComplete='current-password'
placeholder='Enter your password'
label={t('passwordLabel')}
placeholder={t('loginPasswordPlaceholder')}
value={password}
onChange={e => setPassword(e.target.value)}
/>
@@ -556,7 +559,7 @@ const LoginView = () => {
underline='hover'
onClick={handleForgotPassword}
>
Forgot password?
{t('forgotPassword')}
</Link>
</Box>
</Box>
@@ -568,7 +571,7 @@ const LoginView = () => {
</Box>
)}
{hasSocialOptions && <AuthDivider>or continue with</AuthDivider>}
{hasSocialOptions && <AuthDivider>{t('orContinueWith')}</AuthDivider>}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{showSocialLogin && !Capacitor.isNativePlatform() && (
@@ -584,7 +587,7 @@ const LoginView = () => {
}}
onReject={() => {
showError({
title: 'Google Login Failed',
title: t('googleLoginFailed'),
message: "Couldn't log in with Google, please try again",
})
}}
@@ -608,7 +611,7 @@ const LoginView = () => {
} catch (error) {
console.error('Google login error:', error)
showError({
title: 'Google Login Failed',
title: t('googleLoginFailed'),
message: `Couldn't log in with Google, please try again${
error?.message ? `: ${error.message}` : ''
}`,
@@ -637,7 +640,7 @@ const LoginView = () => {
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
title: t('appleLoginFailed'),
message: "Couldn't log in with Apple, please try again",
})
})
@@ -670,7 +673,7 @@ const LoginView = () => {
underline='hover'
onClick={() => Navigate('/signup')}
>
Create one
{t('createOne')}
</Link>
</Typography>
)}

View File

@@ -5,6 +5,7 @@ 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,
@@ -13,6 +14,7 @@ const MFAVerificationModal = ({
onSuccess,
onError,
}) => {
const { t } = useTranslation('auth')
const [verificationCode, setVerificationCode] = useState('')
const [isBackupCode, setIsBackupCode] = useState(false)
const [loading, setLoading] = useState(false)
@@ -21,7 +23,7 @@ const MFAVerificationModal = ({
const handleVerify = async () => {
if (!verificationCode.trim()) {
setError('Please enter a verification code')
setError(t('mfaModal.codeRequired'))
return
}
@@ -37,14 +39,14 @@ const MFAVerificationModal = ({
} else {
const errorData = await response.json()
const message =
errorData.message || 'Invalid verification code. Please try again.'
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 = 'Failed to verify code. Please try again.'
const message = t('mfaModal.verifyFailed')
setError(message)
onError?.(message)
console.error('MFA verification error:', error)
@@ -73,23 +75,23 @@ const MFAVerificationModal = ({
open={open}
onClose={handleClose}
size='md'
title='Two-factor authentication'
title={t('mfaModal.title')}
description={
isBackupCode
? 'Enter one of the backup codes you saved when setting up two-factor authentication.'
: 'Enter the 6-digit code from your authenticator app.'
? t('mfaModal.backupHint')
: t('mfaModal.codeHint')
}
closeOnBackdrop={!loading}
closeOnEscape={!loading}
footer={
<ModalActions
secondary={{
label: 'Cancel',
label: t('common:cancel'),
onClick: handleClose,
disabled: loading,
}}
primary={{
label: 'Verify & Sign In',
label: t('mfaModal.verify'),
onClick: handleVerify,
loading,
disabled: !verificationCode.trim(),
@@ -105,12 +107,12 @@ const MFAVerificationModal = ({
level='body-sm'
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
>
{isBackupCode ? 'Backup code' : 'Verification code'}
{isBackupCode ? t('mfaModal.backupLabel') : t('mfaModal.codeLabel')}
</Typography>
<Input
id='mfa-code'
size='lg'
placeholder={isBackupCode ? 'Enter backup code' : '000000'}
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyDown={handleKeyDown}
@@ -157,8 +159,8 @@ const MFAVerificationModal = ({
}}
>
{isBackupCode
? 'Use authenticator app instead'
: 'Use a backup code instead'}
? t('mfaModal.useAuthenticator')
: t('mfaModal.useBackup')}
</Link>
</Box>
@@ -167,7 +169,7 @@ const MFAVerificationModal = ({
level='body-xs'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
Each backup code can only be used once.
{t('mfaModal.backupOnce')}
</Typography>
)}
</Stack>

View File

@@ -1,6 +1,7 @@
import { Box, Link, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../hooks/useAuth.jsx'
@@ -16,6 +17,7 @@ import {
import AuthShell from './AuthShell'
const SignupView = () => {
const { t } = useTranslation('auth')
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const Navigate = useNavigate()
@@ -38,7 +40,7 @@ const SignupView = () => {
showError({
title: 'Almost there',
message:
'Your account was created, but signing in failed. Please sign in.',
t('signupSignInFailed'),
})
Navigate('/login')
return
@@ -72,36 +74,36 @@ const SignupView = () => {
let isValid = true
if (!username.trim()) {
setUsernameError('Username is required')
setUsernameError(t('usernameRequired'))
isValid = false
}
if (username.length < 4) {
setUsernameError('Username must be at least 4 characters')
setUsernameError(t('usernameMinLength'))
isValid = false
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailError('Invalid email address')
setEmailError(t('invalidEmail'))
isValid = false
}
if (password.length < 8) {
setPasswordError('Password must be between 8 and 64 characters')
setPasswordError(t('passwordLength'))
isValid = false
}
if (password.length > 64) {
setPasswordError('Password must be between 8 and 64 characters')
setPasswordError(t('passwordLength'))
isValid = false
}
if (!displayName.trim()) {
setDisplayNameError('Display name is required')
setDisplayNameError(t('displayNameRequired'))
isValid = false
}
// display name should only contain letters and spaces and numbers:
if (!/^[a-zA-Z0-9 ]+$/.test(displayName)) {
setDisplayNameError('Display name can only contain letters and numbers')
setDisplayNameError(t('displayNameChars'))
isValid = false
}
@@ -127,14 +129,14 @@ const SignupView = () => {
handleLogin(username, password)
} else if (response.status === 403) {
showError({
title: 'Signup Failed',
message: 'Signup disabled, please contact admin',
title: t('signupFailed'),
message: t('signupDisabled'),
})
} else {
console.log('Signup failed')
response.json().then(res => {
showError({
title: 'Signup Failed',
title: t('signupFailed'),
message: res.error || 'An error occurred during signup',
})
})
@@ -145,7 +147,7 @@ const SignupView = () => {
return (
<AuthShell
title='Create your account'
title={t('createYourAccount')}
subtitle={
getPendingInvite()
? 'Create an account and well send your circle join request right after.'
@@ -160,11 +162,11 @@ const SignupView = () => {
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<AuthTextField
label='Display name'
label={t('displayNameLabel')}
id='displayName'
name='displayName'
autoComplete='name'
placeholder='How others see your name'
placeholder={t('displayNamePlaceholder')}
autoFocus
value={displayName}
error={displayNameError}
@@ -175,11 +177,11 @@ const SignupView = () => {
/>
<AuthTextField
label='Username'
label={t('username')}
id='username'
name='username'
autoComplete='username'
placeholder='lowercase letters, numbers, dot and dash'
placeholder={t('usernameHint')}
value={username}
error={usernameError}
onChange={e => {
@@ -189,12 +191,12 @@ const SignupView = () => {
/>
<AuthTextField
label='Email'
label={t('email')}
id='email'
name='email'
type='email'
autoComplete='email'
placeholder='you@example.com'
placeholder={t('emailPlaceholder')}
value={email}
error={emailError}
onChange={e => {
@@ -207,10 +209,11 @@ const SignupView = () => {
id='password'
name='password'
autoComplete='new-password'
placeholder='At least 8 characters'
label={t('passwordLabel')}
placeholder={t('signupPasswordPlaceholder')}
value={password}
error={passwordError}
helper='Use 8 to 64 characters.'
helper={t('signupPasswordHelper')}
onChange={e => {
setPasswordError(null)
setPassword(e.target.value)
@@ -218,14 +221,14 @@ const SignupView = () => {
/>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
Create account
{t('createAccountButton')}
</AuthSubmitButton>
<Typography
level='body-xs'
sx={{ textAlign: 'center', color: 'text.secondary' }}
>
By creating an account you agree to our Terms of Service and Privacy
{t('termsShort')}
Policy.
</Typography>
</Box>

View File

@@ -1,5 +1,6 @@
import { Box, Button } from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useNotification } from '../../service/NotificationProvider'
@@ -9,6 +10,7 @@ import AuthShell from './AuthShell'
import { authButtonSx } from './authStyles'
const UpdatePasswordView = () => {
const { t } = useTranslation('auth')
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
@@ -67,23 +69,22 @@ const UpdatePasswordView = () => {
if (response.ok) {
showNotification({
type: 'success',
title: 'Password Updated',
message:
'Your password has been updated successfully. Redirecting to login...',
title: t('passwordUpdated'),
message: t('passwordUpdatedMsg'),
})
setTimeout(() => {
navigate('/login')
}, 3000)
} else {
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
title: t('passwordUpdateFailed'),
message: t('passwordUpdateFailedMsg'),
})
}
} catch (error) {
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
title: t('passwordUpdateFailed'),
message: t('passwordUpdateFailedMsg'),
})
} finally {
setIsSubmitting(false)
@@ -93,7 +94,7 @@ const UpdatePasswordView = () => {
if (!verificationCode) {
return (
<AuthShell
title='This link is not valid'
title={t('linkInvalid')}
subtitle='The password reset link is incomplete or has already been used. Request a new one to continue.'
footer={<LegalLinks />}
showLogo
@@ -106,7 +107,7 @@ const UpdatePasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/forgot-password')}
>
Request a new link
{t('requestNewLink')}
</Button>
<Button
fullWidth
@@ -116,7 +117,7 @@ const UpdatePasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Back to sign in
{t('backToSignIn')}
</Button>
</Box>
</AuthShell>
@@ -125,7 +126,7 @@ const UpdatePasswordView = () => {
return (
<AuthShell
title='Set a new password'
title={t('setNewPassword')}
subtitle='Choose a password you have not used on this account before.'
footer={<LegalLinks />}
// Reached from an emailed link, usually in a browser: an unbranded page
@@ -138,7 +139,7 @@ const UpdatePasswordView = () => {
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
<AuthPasswordField
label='New password'
label={t('newPassword')}
id='password'
name='password'
autoComplete='new-password'
@@ -151,18 +152,18 @@ const UpdatePasswordView = () => {
/>
<AuthPasswordField
label='Confirm new password'
label={t('confirmNewPassword')}
id='passwordConfirm'
name='passwordConfirm'
autoComplete='new-password'
placeholder='Re-enter your password'
placeholder={t('reenterPassword')}
value={passwordConfirm}
error={passwordConfirmationError}
onChange={handlePasswordConfirmChange}
/>
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
Save password
{t('savePassword')}
</AuthSubmitButton>
<Button
@@ -174,7 +175,7 @@ const UpdatePasswordView = () => {
sx={authButtonSx}
onClick={() => navigate('/login')}
>
Cancel
{t('common:cancel')}
</Button>
</Box>
</AuthShell>