Merge branch 'develop' into i18n/new-namespaces
This commit is contained in:
@@ -63,11 +63,14 @@ const LogoContainer = styled(Box)({
|
||||
marginBottom: '24px',
|
||||
})
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LoadingScreen = ({
|
||||
message = 'Loading...',
|
||||
message = null,
|
||||
showLogo = true,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<LoadingContent>
|
||||
@@ -82,7 +85,7 @@ const LoadingScreen = ({
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t('done')}
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
</LogoContainer>
|
||||
@@ -96,7 +99,7 @@ const LoadingScreen = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<PulsingText level='body-md'>{message}</PulsingText>
|
||||
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
|
||||
</LoadingContent>
|
||||
</LoadingContainer>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Close } from '@mui/icons-material'
|
||||
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { forwardRef, useId } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const WIDTH_BY_SIZE = {
|
||||
sm: 400,
|
||||
@@ -27,6 +26,7 @@ const AppModal = forwardRef(
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
isMobile: isMobileProp,
|
||||
keepMounted = false,
|
||||
mobilePresentation = 'sheet',
|
||||
role = 'dialog',
|
||||
showCloseButton = true,
|
||||
@@ -63,9 +63,16 @@ const AppModal = forwardRef(
|
||||
onClose={handleClose}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
keepMounted
|
||||
keepMounted={keepMounted}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_BACKDROP,
|
||||
// Joy raises portaled listboxes above modals, but its selector misses
|
||||
// Menu because Menu renders with role="menu". Keep the workaround in
|
||||
// the modal primitive so individual menus never need a z-index.
|
||||
...(open && {
|
||||
'& ~ [role="menu"]': {
|
||||
'--unstable_popup-zIndex': 'calc(var(--joy-zIndex-modal) + 1)',
|
||||
},
|
||||
}),
|
||||
display: 'flex',
|
||||
alignItems: isSheet ? 'flex-end' : 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -85,7 +92,6 @@ const AppModal = forwardRef(
|
||||
aria-describedby={descriptionId}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: isFullscreen
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Add, Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ActiveFilterChips = ({
|
||||
chipSize = 'md',
|
||||
chipSx,
|
||||
@@ -18,6 +20,7 @@ const ActiveFilterChips = ({
|
||||
showAddChip = false,
|
||||
totalCount,
|
||||
}) => {
|
||||
const { t } = useTranslation('common')
|
||||
if (!chips.length) {
|
||||
return null
|
||||
}
|
||||
@@ -161,7 +164,7 @@ const ActiveFilterChips = ({
|
||||
...clearButtonSx,
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
34
src/constants/settingsSections.js
Normal file
34
src/constants/settingsSections.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
AccountCircle,
|
||||
Api,
|
||||
Circle,
|
||||
Code,
|
||||
FamilyRestroom,
|
||||
Language,
|
||||
Notifications,
|
||||
Palette,
|
||||
Person,
|
||||
Security,
|
||||
Settings,
|
||||
Storage,
|
||||
ViewSidebar,
|
||||
} from '@mui/icons-material'
|
||||
|
||||
// Single source of truth for the settings sections: id, icon, and access
|
||||
// gating. Titles/descriptions live in locales/settings.json under
|
||||
// `overview.sections.<id>`, keyed off the same ids.
|
||||
export const SETTINGS_SECTIONS = [
|
||||
{ id: 'profile', icon: Person },
|
||||
{ id: 'circle', icon: Circle, parentOnly: true },
|
||||
{ id: 'account', icon: AccountCircle, parentOnly: true },
|
||||
{ id: 'subaccounts', icon: FamilyRestroom },
|
||||
{ id: 'notifications', icon: Notifications },
|
||||
{ id: 'mfa', icon: Security, parentOnly: true },
|
||||
{ id: 'apitokens', icon: Api, parentOnly: true },
|
||||
{ id: 'storage', icon: Storage },
|
||||
{ id: 'sidepanel', icon: ViewSidebar },
|
||||
{ id: 'theme', icon: Palette },
|
||||
{ id: 'localization', icon: Language, isBeta: true },
|
||||
{ id: 'advanced', icon: Settings },
|
||||
{ id: 'developer', icon: Code },
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
// Z-index constants for consistent layering
|
||||
// Lower values appear behind higher values
|
||||
|
||||
export const Z_INDEX = {
|
||||
// Base layer (0-99)
|
||||
BASE: 0,
|
||||
CARD_OVERLAY: 1,
|
||||
DROPDOWN_ITEM: 2,
|
||||
TOOLTIP: 3,
|
||||
|
||||
// UI Components (100-999)
|
||||
SAFE_AREA: 100,
|
||||
CALENDAR: 110,
|
||||
SMART_INPUT: 110,
|
||||
AUTOCOMPLETE: 200,
|
||||
|
||||
// Navigation (1000-1999)
|
||||
NAVBAR: 1000,
|
||||
DRAWER: 999,
|
||||
|
||||
// Modals and Overlays (2000-8999)
|
||||
MODAL_BACKDROP: 2000,
|
||||
MODAL_CONTENT: 2001,
|
||||
MODAL_CLOSE_BUTTON: 2002,
|
||||
// Popups that must float above open modals (portaled to document.body)
|
||||
MODAL_POPOVER: 2100,
|
||||
TOAST: 3000,
|
||||
|
||||
// Critical System UI (9000-9999)
|
||||
LOADING_SCREEN: 9000,
|
||||
ALERTS: 9500,
|
||||
NETWORK_BANNER: 9600,
|
||||
|
||||
// Maximum (10000+) - Reserved for absolute emergencies
|
||||
EMERGENCY: 10000,
|
||||
}
|
||||
|
||||
export default Z_INDEX
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const useConfirmationModal = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
const showConfirmation = (
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText = t('confirm'),
|
||||
cancelText = t('cancel'),
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
|
||||
@@ -5,12 +5,14 @@ import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const useFileUpload = ({
|
||||
draftId,
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
} = {}) => {
|
||||
const { t } = useTranslation('common')
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
@@ -58,14 +60,14 @@ export const useFileUpload = ({
|
||||
|
||||
if (response.status === 507) {
|
||||
showError({
|
||||
title: 'Storage Quota Exceeded',
|
||||
message: 'You have exceeded your quota for uploading files.',
|
||||
title: t('upload.quotaTitle'),
|
||||
message: t('upload.quotaMessage'),
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 413) {
|
||||
showError({
|
||||
title: 'File Too Large',
|
||||
message: 'The file you are trying to upload is too large.',
|
||||
title: t('upload.tooLargeTitle'),
|
||||
message: t('upload.tooLargeMessage'),
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
@@ -76,8 +78,8 @@ export const useFileUpload = ({
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Permission Denied',
|
||||
message: 'You do not have permission to upload files.',
|
||||
title: t('upload.deniedTitle'),
|
||||
message: t('upload.deniedMessage'),
|
||||
})
|
||||
return null
|
||||
} else if (!response.ok) {
|
||||
@@ -105,7 +107,7 @@ export const useFileUpload = ({
|
||||
return null
|
||||
}
|
||||
},
|
||||
[entityType, entityId, draftId, showError, userProfile],
|
||||
[entityType, entityId, draftId, showError, userProfile, t],
|
||||
)
|
||||
|
||||
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
||||
|
||||
@@ -23,6 +23,7 @@ i18n
|
||||
},
|
||||
|
||||
ns: [
|
||||
'auth'
|
||||
'common',
|
||||
'settings',
|
||||
'chores',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
@@ -34,6 +35,7 @@ const uniqueBy = (items, getId) => [
|
||||
|
||||
export const GlobalSearchProvider = ({ children }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation('settings')
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:768px)')
|
||||
@@ -110,6 +112,7 @@ export const GlobalSearchProvider = ({ children }) => {
|
||||
labels,
|
||||
members,
|
||||
isParent: isParentUser(profile),
|
||||
t,
|
||||
choresById: new Map(chores.map(item => [String(item.id), item])),
|
||||
projectsById: new Map(projects.map(item => [String(item.id), item])),
|
||||
membersById: new Map(members.map(item => [String(item.userId), item])),
|
||||
@@ -127,7 +130,7 @@ export const GlobalSearchProvider = ({ children }) => {
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [queryClient])
|
||||
}, [queryClient, t])
|
||||
|
||||
const openSearch = useCallback(
|
||||
(query = '') => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
|
||||
|
||||
const stripHtml = value => {
|
||||
if (!value) return ''
|
||||
if (typeof globalThis.document === 'undefined')
|
||||
@@ -17,26 +19,6 @@ const HISTORY_STATUS = {
|
||||
6: 'rescheduled',
|
||||
}
|
||||
|
||||
const SETTINGS = [
|
||||
['profile', 'Profile', 'Name, avatar and personal details'],
|
||||
['circle', 'Circle', 'Members and household settings', true],
|
||||
['account', 'Account', 'Subscription and account management', true],
|
||||
['subaccounts', 'Subaccounts', 'Manage child accounts'],
|
||||
['notifications', 'Notifications', 'Reminders and notification preferences'],
|
||||
['mfa', 'Multi-factor authentication', 'Secure your account', true],
|
||||
['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
|
||||
['storage', 'Storage', 'Files, backups and device storage'],
|
||||
['sidepanel', 'Side panel', 'Customize navigation'],
|
||||
['theme', 'Appearance', 'Theme, dark mode and colors'],
|
||||
['localization', 'Language and region', 'Language, dates and time formats'],
|
||||
[
|
||||
'advanced',
|
||||
'Advanced settings',
|
||||
'Offline support, webhooks and application behavior',
|
||||
],
|
||||
['developer', 'Developer settings', 'Diagnostics and experimental tools'],
|
||||
]
|
||||
|
||||
const providers = []
|
||||
|
||||
export const registerSearchProvider = provider => {
|
||||
@@ -166,15 +148,15 @@ registerSearchProvider({
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'settings',
|
||||
getDocuments: ({ isParent }) =>
|
||||
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
|
||||
([id, title, description]) =>
|
||||
getDocuments: ({ isParent, t }) =>
|
||||
SETTINGS_SECTIONS.filter(({ parentOnly }) => !parentOnly || isParent).map(
|
||||
({ id }) =>
|
||||
document('settings', {
|
||||
id: `setting:${id}`,
|
||||
entityId: id,
|
||||
title,
|
||||
title: t(`overview.sections.${id}.title`),
|
||||
subtitle: 'Settings',
|
||||
body: description,
|
||||
body: t(`overview.sections.${id}.description`),
|
||||
keywords: `preferences configuration ${id}`,
|
||||
route: `/settings/${id}`,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Alert, Box } from '@mui/joy'
|
||||
import PropTypes from 'prop-types'
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
import Z_INDEX from '../constants/zIndex'
|
||||
|
||||
const FADE_DURATION = 400 // ms
|
||||
const ALERT_DURATION = 5000 // ms
|
||||
@@ -48,7 +47,7 @@ export const AlertsProvider = ({ children }) => {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
zIndex: Z_INDEX.ALERTS,
|
||||
zIndex: 'var(--joy-zIndex-snackbar)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 we’ll 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,16 +38,17 @@ import {
|
||||
ButtonGroup,
|
||||
Chip,
|
||||
Divider,
|
||||
Dropdown,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AppModal from '../../../components/common/AppModal'
|
||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import Priorities from '../../../utils/Priorities'
|
||||
@@ -229,9 +230,8 @@ const ChoreToolbar = ({
|
||||
const [localSelections, setLocalSelections] = useState(defaultSelections())
|
||||
const [savingFilter, setSavingFilter] = useState(false)
|
||||
const [saveFilterName, setSaveFilterName] = useState('')
|
||||
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
|
||||
const [saveMenuOpen, setSaveMenuOpen] = useState(false)
|
||||
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
|
||||
const saveMenuRef = useRef(null)
|
||||
const activeConditions = selectionsToConditions(localSelections)
|
||||
|
||||
// ── badge counts ─────────────────────────────────────────────────────────────
|
||||
@@ -419,13 +419,13 @@ const ChoreToolbar = ({
|
||||
}
|
||||
setSavingFilter(false)
|
||||
setSaveFilterName('')
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
}
|
||||
}, [filterSheetOpen, savingFilter, activeConditions.length])
|
||||
|
||||
@@ -504,7 +504,7 @@ const ChoreToolbar = ({
|
||||
onFilterSaved?.(editingSavedFilter.name)
|
||||
})
|
||||
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}
|
||||
|
||||
@@ -681,7 +681,7 @@ const ChoreToolbar = ({
|
||||
open={filterSheetOpen}
|
||||
isMobile
|
||||
onClose={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
maxHeight='92vh'
|
||||
@@ -747,7 +747,7 @@ const ChoreToolbar = ({
|
||||
disabled={!hasAnyActive && activeConditions.length === 0}
|
||||
onClick={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
onClearAllFilters?.()
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
@@ -756,33 +756,29 @@ const ChoreToolbar = ({
|
||||
</Button>
|
||||
|
||||
{activeConditions.length > 0 ? (
|
||||
<>
|
||||
<Dropdown
|
||||
open={saveMenuOpen}
|
||||
onOpenChange={(_event, isOpen) => setSaveMenuOpen(isOpen)}
|
||||
>
|
||||
<ButtonGroup variant='solid' color='primary'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null ? `Show ${resultCount}` : 'Done'}
|
||||
</Button>
|
||||
<IconButton
|
||||
ref={saveMenuRef}
|
||||
<MenuButton
|
||||
slots={{ root: IconButton }}
|
||||
aria-label='More save options'
|
||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
</MenuButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<Menu
|
||||
anchorEl={saveMenuAnchorEl}
|
||||
open={Boolean(saveMenuAnchorEl)}
|
||||
onClose={() => setSaveMenuAnchorEl(null)}
|
||||
placement='top-end'
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
|
||||
>
|
||||
<Menu placement='top-end'>
|
||||
<MenuItem
|
||||
onClick={handleUpdateFilter}
|
||||
disabled={!editingSavedFilter}
|
||||
@@ -792,7 +788,7 @@ const ChoreToolbar = ({
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setSaveFilterName(
|
||||
editingSavedFilter
|
||||
? `${editingSavedFilter.name} Copy`
|
||||
@@ -805,13 +801,13 @@ const ChoreToolbar = ({
|
||||
Save as New Filter
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setSaveMenuOpen(false)
|
||||
setFilterSheetOpen(false)
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
formatErrorReport,
|
||||
} from '../service/ErrorReportService'
|
||||
import ErrorReportModal from './Modals/ErrorReportModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const getErrorKind = error => {
|
||||
if (!error)
|
||||
@@ -55,6 +56,7 @@ const safeMessage = error => {
|
||||
}
|
||||
|
||||
const Error = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const error = useRouteError()
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -192,7 +194,7 @@ const Error = () => {
|
||||
textAlign='center'
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
Something went wrong
|
||||
{t('errorScreen.title')}
|
||||
</Typography>
|
||||
|
||||
{/* Error message */}
|
||||
@@ -207,8 +209,7 @@ const Error = () => {
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message ??
|
||||
'An unexpected error occurred. Try reloading — it usually fixes it.'}
|
||||
{message ?? t('errorScreen.fallback')}
|
||||
</Typography>
|
||||
|
||||
{/* Primary CTA */}
|
||||
@@ -220,7 +221,7 @@ const Error = () => {
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ width: '100%', mb: 1.5 }}
|
||||
>
|
||||
Try again
|
||||
{t('errorScreen.tryAgain')}
|
||||
</Button>
|
||||
|
||||
{/* Reporting is one tap from the failure, where the context is still
|
||||
@@ -246,7 +247,7 @@ const Error = () => {
|
||||
size='lg'
|
||||
startDecorator={<HomeRounded />}
|
||||
>
|
||||
Home
|
||||
{t('errorScreen.home')}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
@@ -255,7 +256,7 @@ const Error = () => {
|
||||
color='neutral'
|
||||
size='lg'
|
||||
>
|
||||
Login
|
||||
{t('errorScreen.login')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -293,7 +294,9 @@ const Error = () => {
|
||||
}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} error details
|
||||
{showDetails
|
||||
? t('errorScreen.hideDetails')
|
||||
: t('errorScreen.showDetails')}
|
||||
</Button>
|
||||
|
||||
{showDetails && (
|
||||
@@ -312,7 +315,7 @@ const Error = () => {
|
||||
color='neutral'
|
||||
onClick={handleCopy}
|
||||
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||
title='Copy to clipboard'
|
||||
title={t('errorScreen.copyToClipboard')}
|
||||
>
|
||||
<ContentCopyRounded fontSize='small' />
|
||||
</IconButton>
|
||||
@@ -347,7 +350,7 @@ const Error = () => {
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
size='sm'
|
||||
>
|
||||
Error details copied to clipboard
|
||||
{t('errorScreen.copied')}
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Box, Button, Container, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Logo from '../Logo'
|
||||
const Home = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const Navigate = useNavigate()
|
||||
const getCurrentUser = () => {
|
||||
return JSON.parse(localStorage.getItem('user'))
|
||||
@@ -36,7 +38,7 @@ const Home = () => {
|
||||
Navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Get Started!
|
||||
{t('getStarted')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Download } from '@mui/icons-material'
|
||||
import { Box, CircularProgress, Typography } from '@mui/joy'
|
||||
@@ -29,6 +30,7 @@ const downloadUrl = (url, fileName) => {
|
||||
}
|
||||
|
||||
function AttachmentViewerModal({ config }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [imgLoaded, setImgLoaded] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
@@ -49,7 +51,7 @@ function AttachmentViewerModal({ config }) {
|
||||
maxHeight='92vh'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
secondary={{ label: t('close'), onClick: handleClose }}
|
||||
primary={{
|
||||
label: 'Download',
|
||||
startDecorator: <Download />,
|
||||
@@ -73,7 +75,7 @@ function AttachmentViewerModal({ config }) {
|
||||
)}
|
||||
{imgError ? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
Failed to load image.
|
||||
{t('imageLoadFailed')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Input } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [date, setDate] = useState(
|
||||
current ? new Date(current).toISOString().split('T')[0] : '',
|
||||
@@ -22,8 +24,8 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||
title={title}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
|
||||
secondary={{ label: t('cancel'), onClick: onClose }}
|
||||
primary={{ label: t('save'), onClick: handleSave, disabled: !date }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Textarea } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function TextModal({
|
||||
isOpen,
|
||||
@@ -12,6 +13,7 @@ function TextModal({
|
||||
okText,
|
||||
cancelText,
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [text, setText] = useState(current)
|
||||
|
||||
@@ -35,7 +37,7 @@ function TextModal({
|
||||
>
|
||||
<Textarea
|
||||
autoFocus
|
||||
placeholder='Type in here…'
|
||||
placeholder={t('typeHere')}
|
||||
value={text}
|
||||
onChange={event => setText(event.target.value)}
|
||||
minRows={3}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||
const { t } = useTranslation('common')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
return (
|
||||
@@ -13,7 +15,7 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||
fullWidth={true}
|
||||
title='Select User'
|
||||
footer={
|
||||
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
|
||||
<ModalActions secondary={{ label: t('cancel'), onClick: onClose }} />
|
||||
}
|
||||
>
|
||||
<List sx={{ mb: 2 }}>
|
||||
|
||||
@@ -1,22 +1,4 @@
|
||||
import {
|
||||
AccountCircle,
|
||||
Api,
|
||||
BugReport,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Code,
|
||||
FamilyRestroom,
|
||||
Feedback,
|
||||
Language,
|
||||
Notifications,
|
||||
Palette,
|
||||
Person,
|
||||
Security,
|
||||
Settings,
|
||||
Star,
|
||||
Storage,
|
||||
ViewSidebar,
|
||||
} from '@mui/icons-material'
|
||||
import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -37,6 +19,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { SETTINGS_SECTIONS } from '../../constants/settingsSections'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { isParentUser } from '../../utils/UserHelpers'
|
||||
@@ -51,85 +34,13 @@ const SettingsOverview = () => {
|
||||
const [bugReportOpen, setBugReportOpen] = useState(false)
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
id: 'profile',
|
||||
title: t('overview.sections.profile.title'),
|
||||
description: t('overview.sections.profile.description'),
|
||||
icon: <Person />,
|
||||
},
|
||||
{
|
||||
id: 'circle',
|
||||
title: t('overview.sections.circle.title'),
|
||||
description: t('overview.sections.circle.description'),
|
||||
icon: <Circle />,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
title: t('overview.sections.account.title'),
|
||||
description: t('overview.sections.account.description'),
|
||||
icon: <AccountCircle />,
|
||||
},
|
||||
{
|
||||
id: 'subaccounts',
|
||||
title: t('overview.sections.subaccounts.title'),
|
||||
description: t('overview.sections.subaccounts.description'),
|
||||
icon: <FamilyRestroom />,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
title: t('overview.sections.notifications.title'),
|
||||
description: t('overview.sections.notifications.description'),
|
||||
icon: <Notifications />,
|
||||
},
|
||||
{
|
||||
id: 'mfa',
|
||||
title: t('overview.sections.mfa.title'),
|
||||
description: t('overview.sections.mfa.description'),
|
||||
icon: <Security />,
|
||||
},
|
||||
{
|
||||
id: 'apitokens',
|
||||
title: t('overview.sections.apitokens.title'),
|
||||
description: t('overview.sections.apitokens.description'),
|
||||
icon: <Api />,
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
title: t('overview.sections.storage.title'),
|
||||
description: t('overview.sections.storage.description'),
|
||||
icon: <Storage />,
|
||||
},
|
||||
{
|
||||
id: 'sidepanel',
|
||||
title: t('overview.sections.sidepanel.title'),
|
||||
description: t('overview.sections.sidepanel.description'),
|
||||
icon: <ViewSidebar />,
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
title: t('overview.sections.theme.title'),
|
||||
description: t('overview.sections.theme.description'),
|
||||
icon: <Palette />,
|
||||
},
|
||||
{
|
||||
id: 'localization',
|
||||
title: t('overview.sections.localization.title'),
|
||||
description: t('overview.sections.localization.description'),
|
||||
icon: <Language />,
|
||||
isBeta: true,
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: t('overview.sections.advanced.title'),
|
||||
description: t('overview.sections.advanced.description'),
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
title: t('overview.sections.developer.title'),
|
||||
description: t('overview.sections.developer.description'),
|
||||
icon: <Code />,
|
||||
},
|
||||
...SETTINGS_SECTIONS.map(({ icon: Icon, id, isBeta }) => ({
|
||||
id,
|
||||
title: t(`overview.sections.${id}.title`),
|
||||
description: t(`overview.sections.${id}.description`),
|
||||
icon: <Icon />,
|
||||
isBeta,
|
||||
})),
|
||||
{
|
||||
id: 'feedback',
|
||||
title: t('overview.sections.feedback.title'),
|
||||
@@ -154,23 +65,19 @@ const SettingsOverview = () => {
|
||||
navigate(`/settings/${setting.id}`)
|
||||
}
|
||||
|
||||
const parentOnlyIds = SETTINGS_SECTIONS.filter(
|
||||
section => section.parentOnly,
|
||||
).map(section => section.id)
|
||||
|
||||
// Filter settings based on user type
|
||||
const getAvailableSettings = () => {
|
||||
const parentOnlySettings = [
|
||||
'children',
|
||||
'mfa',
|
||||
'apitokens',
|
||||
'circle',
|
||||
'account',
|
||||
]
|
||||
|
||||
if (isParentUser(userProfile)) {
|
||||
// Parent users can access all settings
|
||||
return settingsCards
|
||||
} else {
|
||||
// Child users can only access basic settings
|
||||
return settingsCards.filter(
|
||||
setting => !parentOnlySettings.includes(setting.id),
|
||||
setting => !parentOnlyIds.includes(setting.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Card, IconButton, Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SummaryCard = () => {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<Card>
|
||||
<div className='flex justify-between'>
|
||||
<div>
|
||||
<Typography level='h2'>Summary</Typography>
|
||||
<Typography level='body-xs'>
|
||||
This is a summary of your chores
|
||||
{t('summaryOfChores')}
|
||||
</Typography>
|
||||
</div>
|
||||
<IconButton>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Add } from '@mui/icons-material'
|
||||
import { Divider, Menu, MenuItem } from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const AutocompleteDropdown = ({
|
||||
currentValue,
|
||||
onCreateSuggestion, // Called when the "Create new" row is chosen
|
||||
@@ -62,7 +60,6 @@ const AutocompleteDropdown = ({
|
||||
position: 'relative',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: Z_INDEX.MODAL_POPOVER,
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { Add, KeyboardArrowDown } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dropdown,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import * as chrono from 'chrono-node'
|
||||
@@ -20,6 +29,7 @@ import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import { CreateLabel } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { getIconComponent } from '../../utils/ProjectIcons'
|
||||
import { generateUUID } from '../../utils/UUID'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
@@ -122,6 +132,13 @@ const getInitialProject = () => {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const DEFAULT_PROJECT = {
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
color: '#9CA3AF',
|
||||
icon: 'FolderOpen',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
@@ -175,7 +192,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { isLoading: isProjectsLoading } = useProjects()
|
||||
const { data: projects, isLoading: isProjectsLoading } = useProjects()
|
||||
const createChoreMutation = useCreateChore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -293,6 +310,17 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject)
|
||||
const selectedProject = useMemo(
|
||||
() =>
|
||||
(projectId !== 'default' &&
|
||||
projects?.find(project => project.id === projectId)) ||
|
||||
DEFAULT_PROJECT,
|
||||
[projects, projectId],
|
||||
)
|
||||
const SelectedProjectIcon = useMemo(
|
||||
() => getIconComponent(selectedProject.icon),
|
||||
[selectedProject],
|
||||
)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
|
||||
const [draftId, setDraftId] = useState(() => generateUUID())
|
||||
@@ -1121,6 +1149,50 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
title='Create new task'
|
||||
footer={
|
||||
<ModalActions>
|
||||
{!showScan && !showVoice && projects?.length >= 1 && (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={
|
||||
<SelectedProjectIcon
|
||||
sx={{ fontSize: 18, color: selectedProject.color }}
|
||||
/>
|
||||
}
|
||||
endDecorator={<KeyboardArrowDown sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
mr: 'auto',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
{selectedProject.name}
|
||||
</MenuButton>
|
||||
<Menu
|
||||
placement='top-start'
|
||||
sx={{ minWidth: 200, zIndex: Z_INDEX.MODAL_POPOVER }}
|
||||
>
|
||||
{[DEFAULT_PROJECT, ...projects].map(project => {
|
||||
const ProjectIcon = getIconComponent(project.icon)
|
||||
return (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
selected={projectId === project.id}
|
||||
onClick={() => setProjectId(project.id)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<ProjectIcon
|
||||
sx={{ fontSize: 18, color: project.color }}
|
||||
/>
|
||||
</ListItemDecorator>
|
||||
{project.name}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -223,7 +222,7 @@ const AttachmentPickerField = ({
|
||||
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
sx={{ zIndex: 'calc(var(--joy-zIndex-modal) + 1)' }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Chip, List, ListItem, ListItemButton, Textarea } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
|
||||
const { t } = useTranslation('common')
|
||||
const [filteredOptions, setFilteredOptions] = useState([])
|
||||
const [menuVisible, setMenuVisible] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
@@ -82,7 +84,7 @@ const AutocompleteInput = ({ options, ref, value, onChange, ...props }) => {
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Type here...'
|
||||
placeholder={t('autocompletePlaceholder')}
|
||||
/>
|
||||
{menuVisible && (
|
||||
<List ref={menuRef} style={{ position: 'absolute', zIndex: 1000 }}>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BaseOptionPicker = ({
|
||||
items = [],
|
||||
@@ -199,7 +198,7 @@ const BaseOptionPicker = ({
|
||||
},
|
||||
},
|
||||
]}
|
||||
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
|
||||
sx={{ zIndex: 'calc(var(--joy-zIndex-modal) + 1)' }}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
|
||||
<Sheet
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import { Box, Button, CircularProgress, Container } from '@mui/joy'
|
||||
import { Typography } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Logo from '../../Logo'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
|
||||
const LoadingComponent = () => {
|
||||
const [message, setMessage] = useState('Loading...')
|
||||
const { t } = useTranslation('common')
|
||||
const [message, setMessage] = useState(t('loading'))
|
||||
const [subMessage, setSubMessage] = useState('')
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnline) {
|
||||
setMessage('You are offline')
|
||||
setSubMessage(
|
||||
'This not available while offline. Please check your internet connection and try again.',
|
||||
)
|
||||
setMessage(t('loadingOffline'))
|
||||
setSubMessage(t('loadingOfflineSub'))
|
||||
}
|
||||
}, [isOnline])
|
||||
}, [isOnline, t])
|
||||
useEffect(() => {
|
||||
networkManager.registerNetworkListener(isOnline => setIsOnline(isOnline))
|
||||
|
||||
// if loading took more than 5 seconds update submessage to mention there might be an error:
|
||||
const timeout = setTimeout(() => {
|
||||
if (networkManager.isOnline) {
|
||||
setSubMessage(
|
||||
'This is taking longer than usual. There might be an issue.',
|
||||
)
|
||||
setSubMessage(t('loadingSlow'))
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
@@ -67,7 +65,7 @@ const LoadingComponent = () => {
|
||||
window.location.href = '/' // navigate back to the home page
|
||||
}}
|
||||
>
|
||||
Navigate Back
|
||||
{t('navigateBack')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
Archive,
|
||||
ArrowBack,
|
||||
BugReport,
|
||||
FilterAlt,
|
||||
FolderOpen,
|
||||
History,
|
||||
@@ -30,10 +31,11 @@ import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { version } from '../../../package.json'
|
||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useGlobalSearch } from '../../search/GlobalSearchContext'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import ErrorReportModal from '../Modals/ErrorReportModal'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
@@ -45,6 +47,7 @@ const NavBar = () => {
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [bugReportOpen, setBugReportOpen] = useState(false)
|
||||
|
||||
const links = [
|
||||
{
|
||||
@@ -206,7 +209,7 @@ const NavBar = () => {
|
||||
? `calc(var(--safe-area-inset-top, 0px))`
|
||||
: '',
|
||||
position: 'sticky',
|
||||
zIndex: Z_INDEX.NAVBAR,
|
||||
zIndex: 'var(--joy-zIndex-popup)',
|
||||
top: 0,
|
||||
minHeight: '35px',
|
||||
backgroundColor: 'var(--joy-palette-background-body)',
|
||||
@@ -239,7 +242,6 @@ const NavBar = () => {
|
||||
// height:
|
||||
// 'calc(100vh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))',
|
||||
overflow: 'auto',
|
||||
zIndex: Z_INDEX.DRAWER,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -296,6 +298,17 @@ const NavBar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>Upgrade to Plus</ListItemContent>
|
||||
</ListItemButton> */}
|
||||
<ListItemButton
|
||||
onClick={() => setBugReportOpen(true)}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<BugReport />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>{t('navigation.reportBug')}</ListItemContent>
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
apiClient.handleLogout()
|
||||
@@ -329,6 +342,10 @@ const NavBar = () => {
|
||||
</List>
|
||||
</div>
|
||||
</Drawer>
|
||||
<ErrorReportModal
|
||||
open={bugReportOpen}
|
||||
onClose={() => setBugReportOpen(false)}
|
||||
/>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Box from '@mui/joy/Box'
|
||||
import ListItemDecorator from '@mui/joy/ListItemDecorator'
|
||||
import Tabs from '@mui/joy/Tabs'
|
||||
@@ -10,6 +11,7 @@ import Search from '@mui/icons-material/Search'
|
||||
import Person from '@mui/icons-material/Person'
|
||||
|
||||
export default function NavBarMobile() {
|
||||
const { t } = useTranslation('common')
|
||||
const [index, setIndex] = React.useState(0)
|
||||
const colors = ['primary', 'danger', 'success', 'warning']
|
||||
return (
|
||||
@@ -32,7 +34,7 @@ export default function NavBarMobile() {
|
||||
>
|
||||
<Tabs
|
||||
size='lg'
|
||||
aria-label='Bottom Navigation'
|
||||
aria-label={t('bottomNav')}
|
||||
value={index}
|
||||
onChange={(event, value) => setIndex(value)}
|
||||
sx={theme => ({
|
||||
@@ -98,7 +100,7 @@ export default function NavBarMobile() {
|
||||
<ListItemDecorator>
|
||||
<Person />
|
||||
</ListItemDecorator>
|
||||
Profile
|
||||
{t('profile')}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { WifiOff } from '@mui/icons-material'
|
||||
import { Alert, Box } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
|
||||
const NetworkBanner = () => {
|
||||
@@ -56,7 +56,7 @@ const NetworkBanner = () => {
|
||||
paddingTop: `calc(var(--safe-area-inset-top, 0px))`,
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: Z_INDEX.NETWORK_BANNER,
|
||||
zIndex: 'var(--joy-zIndex-snackbar)',
|
||||
pt: `calc( env(safe-area-inset-top, 0px))`,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
|
||||
Reference in New Issue
Block a user