Merge branch 'develop' into 0811-fixes

This commit is contained in:
Mohamad Tarbin
2026-08-13 19:28:48 -04:00
committed by GitHub
69 changed files with 8726 additions and 2212 deletions

View File

@@ -34,7 +34,7 @@ export const AuthField = ({ label, error, helper, children, ...formProps }) => (
)
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
<AuthField label={label} error={error} helper={helper}>
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
</AuthField>
)
@@ -49,7 +49,7 @@ export const AuthPasswordField = ({
const [visible, setVisible] = useState(false)
return (
<AuthField label={label} error={error} helper={helper}>
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
<Input
size='lg'
type={visible ? 'text' : 'password'}

View File

@@ -576,6 +576,7 @@ const FilterView = () => {
}}
>
<IconButton
data-testid='open-add-filter-modal'
color='primary'
variant='solid'
sx={{

View File

@@ -495,6 +495,7 @@ const ProjectView = () => {
}}
>
<IconButton
data-testid='open-add-project-modal'
color='primary'
variant='solid'
sx={{

View File

@@ -10,8 +10,9 @@ import {
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
@@ -26,6 +27,7 @@ import TextModal from '../Modals/Inputs/TextModal'
import SettingsLayout from './SettingsLayout'
const APITokenSettings = () => {
const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile()
const { showNotification } = useNotification()
const { fmt } = useLocalization()
@@ -38,8 +40,8 @@ const APITokenSettings = () => {
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText = t('common.confirm'),
cancelText = t('common.cancel'),
color = 'primary',
) => {
setConfirmModalConfig({
@@ -80,23 +82,18 @@ const APITokenSettings = () => {
}
return (
<SettingsLayout title='API Tokens'>
<SettingsLayout title={t('apiTokens.title')}>
<div className='grid gap-4 py-4' id='apitokens'>
<Typography level='h3'>Access Token</Typography>
<Typography level='h3'>{t('apiTokens.accessToken')}</Typography>
<Divider />
<Typography level='body-sm'>
Create token to use with the API to update things that trigger task or
chores
</Typography>
<Typography level='body-sm'>{t('apiTokens.description')}</Typography>
{!isPlusAccount(userProfile) && (
<>
<Chip variant='soft' color='warning'>
Plus Feature
{t('common.plusFeature')}
</Chip>
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
API tokens are not available in the Basic plan. Upgrade to Plus to
generate API tokens for integrating with external systems and
automating your tasks.
{t('apiTokens.plusNotice')}
</Typography>
</>
)}
@@ -125,7 +122,9 @@ const APITokenSettings = () => {
setShowTokenId(token.id)
}}
>
{showTokenId === token?.id ? 'Hide' : 'Show'} Token
{showTokenId === token?.id
? t('apiTokens.hideToken')
: t('apiTokens.showToken')}
</Button>
<Button
@@ -133,15 +132,15 @@ const APITokenSettings = () => {
color='danger'
onClick={() => {
showConfirmation(
`Are you sure you want to remove ${token.name}?`,
'Remove Token',
t('apiTokens.removeMessage', { name: token.name }),
t('apiTokens.removeTitle'),
() => {
DeleteLongLiveToken(token.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
title: 'Removed',
message: 'API token has been removed',
title: t('apiTokens.removedTitle'),
message: t('apiTokens.removedMessage'),
})
const newTokens = tokens.filter(
t => t.id !== token.id,
@@ -150,13 +149,13 @@ const APITokenSettings = () => {
}
})
},
'Remove',
'Cancel',
t('common.remove'),
t('common.cancel'),
'danger',
)
}}
>
Remove
{t('common.remove')}
</Button>
</Box>
</Box>
@@ -174,7 +173,7 @@ const APITokenSettings = () => {
navigator.clipboard.writeText(token.token)
showNotification({
type: 'success',
message: 'Token copied to clipboard',
message: t('apiTokens.tokenCopied'),
})
setShowTokenId(null)
}}
@@ -200,15 +199,15 @@ const APITokenSettings = () => {
setIsGetTokenNameModalOpen(true)
}}
>
Generate New Token
{t('apiTokens.generateNew')}
</Button>
<TextModal
isOpen={isGetTokenNameModalOpen}
title='Give a name for your new token, something to remember it by.'
title={t('apiTokens.nameModalTitle')}
onClose={() => {
setIsGetTokenNameModalOpen(false)
}}
okText={'Generate Token'}
okText={t('apiTokens.generateToken')}
onSave={handleSaveToken}
/>

View File

@@ -4,6 +4,8 @@ import { Purchases } from '@revenuecat/purchases-capacitor'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SubscriptionModal from '../../components/SubscriptionModal'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useUserProfile } from '../../queries/UserQueries'
@@ -15,6 +17,7 @@ import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
import SettingsLayout from './SettingsLayout'
const AccountSettings = () => {
const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
@@ -42,43 +45,49 @@ const AccountSettings = () => {
const getSubscriptionDetails = () => {
if (userProfile?.subscription === 'active') {
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${fmt.date(userProfile?.expiration)}.`
return t('accountSettings.activeDescription', {
date: fmt.date(userProfile?.expiration),
})
} else if (userProfile?.subscription === 'cancelled') {
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${fmt.date(userProfile?.expiration)}.`
return t('accountSettings.cancelledDescription', {
date: fmt.date(userProfile?.expiration),
})
} else {
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
return t('accountSettings.freeDescription')
}
}
const getSubscriptionStatus = () => {
if (userProfile?.subscription === 'active') {
return `Plus`
return t('accountSettings.plus')
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return `Plus(until ${fmt.date(userProfile?.expiration)})`
return t('accountSettings.plusUntil', {
date: fmt.date(userProfile?.expiration),
})
}
return `Free`
return t('accountSettings.free')
} else {
return `Free`
return t('accountSettings.free')
}
}
if (!userProfile) {
return (
<SettingsLayout title='Account Settings'>
<div>Loading...</div>
<SettingsLayout title={t('accountSettings.title')}>
<div>{t('common.loading')}</div>
</SettingsLayout>
)
}
return (
<SettingsLayout title='Account Settings'>
<SettingsLayout title={t('accountSettings.title')}>
<div className='grid gap-4'>
<Typography level='body-md'>
Change your account settings, type or update your password
{t('accountSettings.description')}
</Typography>
<Typography level='title-md' mb={-1}>
Account Type : {getSubscriptionStatus()}
{t('accountSettings.accountType', { type: getSubscriptionStatus() })}
</Typography>
<Typography level='body-sm'>{getSubscriptionDetails()}</Typography>
<Box>
@@ -95,9 +104,8 @@ const AccountSettings = () => {
onClick={async () => {
if (Capacitor.isNativePlatform()) {
try {
const { RevenueCatUI } = await import(
'@revenuecat/purchases-capacitor-ui'
)
const { RevenueCatUI } =
await import('@revenuecat/purchases-capacitor-ui')
const offering = await Purchases.getOfferings()
await RevenueCatUI.presentPaywall({
@@ -110,8 +118,7 @@ const AccountSettings = () => {
queryClient.refetchQueries(['userProfile'])
showNotification({
type: 'success',
message:
'Purchase successful! Please restart the app to access Plus features.',
message: t('accountSettings.purchase.success'),
})
}
} catch (error) {
@@ -122,57 +129,53 @@ const AccountSettings = () => {
} else if (error.code === '2') {
showNotification({
type: 'error',
message:
'Store connection issue. Please check your network and try again.',
message: t('accountSettings.purchase.storeConnection'),
})
} else if (error.code === '3') {
showNotification({
type: 'error',
message:
'Purchases are not allowed on this device. Please check your device restrictions.',
message: t('accountSettings.purchase.notAllowed'),
})
} else if (error.code === '4') {
showNotification({
type: 'error',
message:
'This subscription is not available. Please try again later.',
message: t('accountSettings.purchase.unavailable'),
})
} else if (error.code === '5') {
showNotification({
type: 'error',
message:
'This purchase has already been processed. If you believe this is an error, please contact support.',
message: t('accountSettings.purchase.alreadyProcessed'),
})
} else if (error.code === '6') {
showNotification({
type: 'error',
message:
'Purchase receipt missing. Please try purchasing again.',
message: t('accountSettings.purchase.receiptMissing'),
})
} else if (error.code === '7') {
showNotification({
type: 'error',
message:
'Network error. Please check your connection and try again.',
message: t('accountSettings.purchase.networkError'),
})
} else if (error.code === '8') {
showNotification({
type: 'error',
message:
'Invalid purchase receipt. Please contact support if this persists.',
message: t('accountSettings.purchase.invalidReceipt'),
})
} else if (error.code === '9') {
showNotification({
type: 'warning',
message:
'Payment is pending approval. You will receive access once approved.',
message: t('accountSettings.purchase.pending'),
})
} else {
console.error('Unexpected purchase error:', error)
console.error('Error occurred in purchase flow')
showNotification({
type: 'error',
message: `Purchase failed: ${error.message || 'Unknown error'}. Please try again or contact support.`,
message: t('accountSettings.purchase.failed', {
error:
error.message ||
t('accountSettings.purchase.unknownError'),
}),
})
}
}
@@ -181,7 +184,7 @@ const AccountSettings = () => {
}
}}
>
Upgrade
{t('accountSettings.upgrade')}
</Button>
{userProfile?.subscription === 'active' && (
@@ -197,14 +200,14 @@ const AccountSettings = () => {
setNativeCancelModal(true)
}}
>
Cancel
{t('accountSettings.cancel')}
</Button>
)}
</Box>
{import.meta.env.VITE_IS_SELF_HOSTED === 'true' && (
<Box>
<Typography level='title-md' mb={1}>
Password :
{t('accountSettings.password')}
</Typography>
<Typography mb={1} level='body-sm'></Typography>
<Button
@@ -213,7 +216,7 @@ const AccountSettings = () => {
setChangePasswordModal(true)
}}
>
Change Password
{t('accountSettings.changePassword')}
</Button>
{changePasswordModal ? (
<PassowrdChangeModal
@@ -224,12 +227,12 @@ const AccountSettings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Password changed successfully',
message: t('accountSettings.passwordChanged'),
})
} else {
showNotification({
type: 'error',
message: 'Password change failed',
message: t('accountSettings.passwordChangeFailed'),
})
}
})
@@ -243,18 +246,17 @@ const AccountSettings = () => {
<Box>
<Typography level='title-md' mb={1} color='danger'>
Danger Zone
{t('accountSettings.dangerZone')}
</Typography>
<Typography level='body-sm' mb={2} color='neutral'>
Once you delete your account, there is no going back. Please be
certain.
{t('accountSettings.dangerZoneDescription')}
</Typography>
<Button
variant='outlined'
color='danger'
onClick={() => setUserDeletionModal(true)}
>
Delete Account
{t('accountSettings.deleteAccount')}
</Button>
</Box>
</div>
@@ -271,7 +273,7 @@ const AccountSettings = () => {
if (success) {
showNotification({
type: 'success',
message: 'Account deleted successfully',
message: t('accountSettings.accountDeleted'),
})
}
}}
@@ -287,13 +289,13 @@ const AccountSettings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Subscription cancelled',
message: t('accountSettings.subscriptionCancelled'),
})
window.location.reload()
} else {
showNotification({
type: 'error',
message: 'Failed to cancel subscription',
message: t('accountSettings.subscriptionCancelFailed'),
})
}
})

View File

@@ -10,6 +10,8 @@ import {
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RealTimeSettings from '../../components/RealTimeSettings'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
@@ -27,6 +29,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import SettingsLayout from './SettingsLayout'
const AdvancedSettings = () => {
const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
@@ -73,7 +76,7 @@ const AdvancedSettings = () => {
queryClient.invalidateQueries()
showNotification({
type: 'success',
message: 'Offline mode turned off and local data was cleared',
message: t('advanced.offlineDisabled'),
})
} catch {
setOfflineFeatureEnabled(false)
@@ -82,8 +85,7 @@ const AdvancedSettings = () => {
queryClient.invalidateQueries()
showNotification({
type: 'warning',
message:
'Offline mode was turned off, but some local data may still be stored',
message: t('advanced.offlineDisabledPartial'),
})
} finally {
setOfflineLoading(false)
@@ -93,11 +95,10 @@ const AdvancedSettings = () => {
const showDisableOfflineConfirmation = () => {
setConfirmModalConfig({
isOpen: true,
title: 'Turn Off Offline Mode',
message:
'Turning off offline mode will remove unsynced offline changes and saved offline data on this device/browser. Do you want to continue?',
confirmText: 'Turn Off & Clear Data',
cancelText: 'Cancel',
title: t('advanced.offlineDisableTitle'),
message: t('advanced.offlineDisableMessage'),
confirmText: t('advanced.offlineDisableConfirm'),
cancelText: t('common.cancel'),
color: 'danger',
onClose: isConfirmed => {
setConfirmModalConfig({})
@@ -117,7 +118,7 @@ const AdvancedSettings = () => {
queryClient.invalidateQueries()
showNotification({
type: 'success',
message: 'Offline mode turned on for this device/browser',
message: t('advanced.offlineEnabled'),
})
return
}
@@ -134,15 +135,12 @@ const AdvancedSettings = () => {
// }
return (
<SettingsLayout title='Advanced Settings'>
<SettingsLayout title={t('advanced.title')}>
<div className='grid gap-4'>
<Typography level='body-md'>
Configure advanced features like webhooks and real-time updates for
enhanced productivity.
</Typography>
<Typography level='body-md'>{t('advanced.description')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<Typography level='title-lg'>Offline Support</Typography>
<Typography level='title-lg'>{t('advanced.offlineTitle')}</Typography>
<Chip
variant='outlined'
size='sm'
@@ -154,43 +152,36 @@ const AdvancedSettings = () => {
borderColor: 'warning.main',
}}
>
Early Access
{t('common.earlyAccess')}
</Chip>
</Box>
<Typography level='body-md' mt={-1}>
Keep using Donetick when you're offline on this device/browser. Your
changes are saved locally and synced when you're back online.
{t('advanced.offlineDescription')}
</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
checked={offlineEnabled}
onChange={handleOfflineToggle}
variant='soft'
label='Enable Offline Support'
label={t('advanced.offlineToggle')}
disabled={offlineLoading}
overlay
/>
<FormHelperText>
Turning this off removes unsynced offline changes and saved offline
data from this device/browser.
</FormHelperText>
<FormHelperText>{t('advanced.offlineHelper')}</FormHelperText>
</FormControl>
{/* Webhook Settings - Only show for admins */}
{isAdmin && (
<>
<Typography level='title-lg' mt={2}>
Webhook Integration
{t('advanced.webhookTitle')}
</Typography>
<Typography level='body-md' mt={-1}>
Webhooks allow you to send real-time notifications to other
services when events happen in your Circle. Configure a webhook
URL to receive real-time updates.
{t('advanced.webhookDescription')}
</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Webhook notifications are not available in the Basic plan.
Upgrade to Plus to receive real-time updates via webhooks.
{t('advanced.webhookPlusNotice')}
</Typography>
)}
<FormControl sx={{ mt: 1 }}>
@@ -204,7 +195,7 @@ const AdvancedSettings = () => {
}
}}
variant='soft'
label='Enable Webhook'
label={t('advanced.webhookToggle')}
disabled={!isPlusAccount(userProfile)}
overlay
/>
@@ -213,10 +204,10 @@ const AdvancedSettings = () => {
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
}}
>
Enable webhook notifications for tasks and things updates.{' '}
{t('advanced.webhookHelper')}{' '}
{userProfile && !isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning'>
Plus Feature
{t('common.plusFeature')}
</Chip>
)}
</FormHelperText>
@@ -224,7 +215,9 @@ const AdvancedSettings = () => {
{webhookURL !== null && (
<Box>
<Typography level='title-sm'>Webhook URL</Typography>
<Typography level='title-sm'>
{t('advanced.webhookURL')}
</Typography>
<Input
value={webhookURL ? webhookURL : ''}
onChange={e => setWebhookURL(e.target.value)}
@@ -247,19 +240,19 @@ const AdvancedSettings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Webhook URL updated successfully',
message: t('advanced.webhookUpdated'),
})
} else {
showNotification({
type: 'error',
message: 'Failed to update webhook URL',
message: t('advanced.webhookUpdateFailed'),
})
}
})
}}
disabled={!isPlusAccount(userProfile)}
>
Save
{t('common.save')}
</Button>
</Box>
)}
@@ -268,11 +261,10 @@ const AdvancedSettings = () => {
{/* Real-time Settings */}
<Typography level='title-lg' mt={2}>
Real-time Updates
{t('advanced.realtimeTitle')}
</Typography>
<Typography level='body-md' mt={-1}>
Configure how you receive live updates when tasks and activities
change in your circle.
{t('advanced.realtimeDescription')}
</Typography>
<RealTimeSettings />

View File

@@ -13,6 +13,9 @@ import {
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useChildUsers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
@@ -28,6 +31,8 @@ import PasswordChangeModal from '../Modals/Inputs/PasswordChangeModal'
import SettingsLayout from './SettingsLayout'
const ChildUserSettings = () => {
const { t } = useTranslation('settings')
const { fmt } = useLocalization()
const { data: userProfile } = useUserProfile()
const { data: childUsers, isLoading, refetch } = useChildUsers()
const { showNotification } = useNotification()
@@ -54,18 +59,20 @@ const ChildUserSettings = () => {
const result = await response.json()
showNotification({
type: 'success',
message: `Child account "${result.res.displayName}" created successfully!`,
message: t('subaccounts.createdSuccess', {
name: result.res.displayName,
}),
})
refetch()
queryClient.invalidateQueries(['childUsers'])
} else {
const error = await response.json()
throw new Error(error.error || 'Failed to create child user')
throw new Error(error.error || t('subaccounts.createFailedGeneric'))
}
} catch (error) {
showNotification({
type: 'error',
message: `Failed to create child account: ${error.message}`,
message: t('subaccounts.createFailed', { error: error.message }),
})
throw error
}
@@ -80,24 +87,28 @@ const ChildUserSettings = () => {
if (response.ok) {
showNotification({
type: 'success',
message: 'Child password updated successfully',
message: t('subaccounts.passwordUpdated'),
})
} else {
const error = await response.json()
throw new Error(error.error || 'Failed to update password')
throw new Error(
error.error || t('subaccounts.passwordUpdateFailedGeneric'),
)
}
} catch (error) {
showNotification({
type: 'error',
message: `Failed to update password: ${error.message}`,
message: t('subaccounts.passwordUpdateFailed', {
error: error.message,
}),
})
}
}
const handleDeleteChild = async (childId, childName) => {
showConfirmation(
`Are you sure you want to delete the child account "${childName}"? This action cannot be undone.`,
'Delete Sub Account',
t('subaccounts.deleteConfirmMessage', { name: childName }),
t('subaccounts.deleteConfirmTitle'),
async () => {
setDeletingChildId(childId)
try {
@@ -106,50 +117,46 @@ const ChildUserSettings = () => {
if (response.ok) {
showNotification({
type: 'success',
message: `Sub account "${childName}" deleted successfully`,
message: t('subaccounts.deleted', { name: childName }),
})
refetch()
queryClient.invalidateQueries(['childUsers'])
} else {
const error = await response.json()
throw new Error(error.error || 'Failed to delete Sub user')
throw new Error(error.error || t('subaccounts.deleteFailedGeneric'))
}
} catch (error) {
showNotification({
type: 'error',
message: `Failed to delete Sub account: ${error.message}`,
message: t('subaccounts.deleteFailed', { error: error.message }),
})
} finally {
setDeletingChildId(null)
}
},
'Delete',
'Cancel',
t('common.delete'),
t('common.cancel'),
'danger',
)
}
if (!isParentUser) {
return (
<SettingsLayout title='Sub Account Management'>
<SettingsLayout title={t('subaccounts.notParentTitle')}>
<Typography level='body-md' color='warning'>
Only primary users can manage sub accounts.
{t('subaccounts.notParentMessage')}
</Typography>
</SettingsLayout>
)
}
return (
<SettingsLayout title='Managed Accounts'>
<SettingsLayout title={t('subaccounts.title')}>
<div className='grid gap-4'>
<Typography level='body-md'>
Manage sub accounts. Sub account users can log in and complete
assigned tasks.
</Typography>
<Typography level='body-md'>{t('subaccounts.description')}</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Sub account limited to 1 on Free plan. Upgrade to Plus to have up to
5 sub accounts.
{t('subaccounts.freePlanNotice')}
</Typography>
)}
<Box
@@ -160,33 +167,32 @@ const ChildUserSettings = () => {
}}
>
<Typography level='title-lg'>
Sub Accounts ({childUsers?.length || 0})
{t('subaccounts.count', { count: childUsers?.length || 0 })}
</Typography>
<Button
startDecorator={<PersonAddIcon />}
onClick={() => setCreateModalOpen(true)}
>
Add Sub Account
{t('subaccounts.add')}
</Button>
</Box>
{isLoading ? (
<Typography>Loading sub accounts...</Typography>
<Typography>{t('subaccounts.loading')}</Typography>
) : childUsers?.length === 0 ? (
<Card variant='soft' sx={{ textAlign: 'center', py: 4 }}>
<CardContent>
<Typography level='title-md' mb={1}>
No Sub Accounts
{t('subaccounts.emptyTitle')}
</Typography>
<Typography level='body-sm' mb={3}>
Create sub accounts so team members can log in and complete
their assigned tasks.
{t('subaccounts.emptyDescription')}
</Typography>
<Button
startDecorator={<PersonAddIcon />}
onClick={() => setCreateModalOpen(true)}
>
Add Your First Sub Account
{t('subaccounts.addFirst')}
</Button>
</CardContent>
</Card>
@@ -206,11 +212,14 @@ const ChildUserSettings = () => {
{child.displayName || child.username}
</Typography>
<Typography level='body-sm' color='neutral'>
Username: {child.username}
{t('subaccounts.username', {
username: child.username,
})}
</Typography>
<Typography level='body-xs' color='neutral'>
Created:{' '}
{new Date(child.createdAt).toLocaleDateString()}
{t('subaccounts.created', {
date: fmt.date(child.createdAt),
})}
</Typography>
</Box>
@@ -222,7 +231,7 @@ const ChildUserSettings = () => {
setSelectedChildId(child.id)
setPasswordModalOpen(true)
}}
title='Change Password'
title={t('subaccounts.changePassword')}
>
<EditIcon />
</IconButton>
@@ -237,7 +246,7 @@ const ChildUserSettings = () => {
)
}
loading={deletingChildId === child.id}
title='Delete Account'
title={t('subaccounts.deleteAccount')}
>
<DeleteIcon />
</IconButton>
@@ -253,21 +262,19 @@ const ChildUserSettings = () => {
<Box>
<Typography level='title-md' mb={2}>
How Managed Accounts Work
{t('subaccounts.howItWorksTitle')}
</Typography>
<Typography level='body-sm' mb={1}>
Managed accounts created by the primary user, these specific for
user you want to have ability to delete and reset password.
{t('subaccounts.howItWorks1')}
</Typography>
<Typography level='body-sm' mb={1}>
Sub accounts can log in with their own username and password.
{t('subaccounts.howItWorks2')}
</Typography>
<Typography level='body-sm' mb={1}>
Managed accounts can complete tasks but have limited
administrative permissions
{t('subaccounts.howItWorks3')}
</Typography>
<Typography level='body-sm'>
Managed accounts automatically added to your circle
{t('subaccounts.howItWorks4')}
</Typography>
</Box>
</div>

View File

@@ -15,6 +15,7 @@ import {
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
@@ -36,6 +37,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import SettingsLayout from './SettingsLayout'
const CircleSettings = () => {
const { t } = useTranslation('settings')
const { data: userProfile } = useUserProfile()
const queryClient = useQueryClient()
const { showNotification } = useNotification()
@@ -55,8 +57,8 @@ const CircleSettings = () => {
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText = t('common.confirm'),
cancelText = t('common.cancel'),
color = 'primary',
) => {
setConfirmModalConfig({
@@ -75,6 +77,29 @@ const CircleSettings = () => {
})
}
const roleOptions = [
{
value: 'member',
label: t('circleSettings.roles.member'),
description: t('circleSettings.roles.memberDescription'),
},
{
value: 'manager',
label: t('circleSettings.roles.manager'),
description: t('circleSettings.roles.managerDescription'),
},
{
value: 'admin',
label: t('circleSettings.roles.admin'),
description: t('circleSettings.roles.adminDescription'),
},
]
// Roles come back from the API as lowercase identifiers, so fall back to the
// raw value for anything the translations don't cover yet.
const roleLabel = role =>
roleOptions.find(option => option.value === role)?.label ?? role
const refreshMemberRequests = async () => {
setIsRefreshing(true)
try {
@@ -85,7 +110,7 @@ const CircleSettings = () => {
} catch (error) {
showNotification({
type: 'error',
message: 'Failed to refresh member requests',
message: t('circleSettings.refreshFailed'),
})
} finally {
setIsRefreshing(false)
@@ -129,21 +154,21 @@ const CircleSettings = () => {
: ''
const shareInvite = async () => {
const circleName = userCircles[0]?.name || 'my Circle'
const circleName = userCircles[0]?.name || t('circleSettings.myCircle')
try {
await Share.share({
title: `Join ${circleName} on Donetick`,
text: `I'd like to invite you to join ${circleName} on Donetick.`,
title: t('circleSettings.shareTitle', { name: circleName }),
text: t('circleSettings.shareText', { name: circleName }),
url: inviteLink,
dialogTitle: 'Share Circle invite',
dialogTitle: t('circleSettings.shareDialogTitle'),
})
} catch (error) {
if (error?.message?.toLowerCase().includes('cancel')) return
await navigator.clipboard.writeText(inviteLink)
showNotification({
type: 'success',
message: 'Invite link copied to clipboard',
message: t('circleSettings.linkCopied'),
})
}
}
@@ -153,19 +178,16 @@ const CircleSettings = () => {
}
return (
<SettingsLayout title='Circle Settings'>
<SettingsLayout title={t('circleSettings.title')}>
<div className='grid gap-4'>
<Typography level='body-md'>
Your account is automatically connected to a Circle when you create or
join one. Easily invite friends by sharing the unique Circle code or
link below. You'll receive a notification below when someone requests
to join your Circle.
{t('circleSettings.description')}
</Typography>
<Box>
<Typography level='title-sm' sx={{ mb: 1 }}>
{userCircles[0]?.userRole === 'member'
? `You part of ${userCircles[0]?.name} `
: `You circle code is:`}
{userCircles[0]?.userRole === 'member'
? t('circleSettings.memberOf', { name: userCircles[0]?.name })
: t('circleSettings.yourCircleCode')}
</Typography>
<Input
value={inviteCode}
@@ -191,11 +213,11 @@ const CircleSettings = () => {
navigator.clipboard.writeText(userCircles[0]?.invite_code)
showNotification({
type: 'success',
message: 'Code copied to clipboard',
message: t('circleSettings.codeCopied'),
})
}}
>
Copy Code
{t('circleSettings.copyCode')}
</Button>
<Button
variant='soft'
@@ -203,7 +225,7 @@ const CircleSettings = () => {
startDecorator={<IosShare />}
onClick={shareInvite}
>
Share Invite
{t('circleSettings.shareInvite')}
</Button>
{userCircles.length > 0 &&
userCircles[0]?.userRole === 'member' && (
@@ -212,36 +234,38 @@ const CircleSettings = () => {
variant='outlined'
onClick={() => {
showConfirmation(
'Are you sure you want to leave your circle?',
'Leave Circle',
t('circleSettings.leaveConfirmMessage'),
t('circleSettings.leaveConfirmTitle'),
() => {
LeaveCircle(userCircles[0]?.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Left circle successfully',
message: t('circleSettings.leftCircle'),
})
} else {
showNotification({
type: 'error',
message: 'Failed to leave circle',
message: t('circleSettings.leaveFailed'),
})
}
})
},
'Leave',
'Cancel',
t('circleSettings.leaveConfirmButton'),
t('common.cancel'),
'danger',
)
}}
>
Leave Circle
{t('circleSettings.leave')}
</Button>
)}
</Box>
</Box>
<Typography level='title-md'>Circle Members</Typography>
<Typography level='title-md'>
{t('circleSettings.circleMembers')}
</Typography>
{circleMembers.map(member => (
<Card key={member.id} className='p-4'>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
@@ -249,19 +273,27 @@ const CircleSettings = () => {
<Typography level='body-md'>
{member.displayName.charAt(0).toUpperCase() +
member.displayName.slice(1)}
{member.userId === userProfile.id ? '(You)' : ''}{' '}
{member.userId === userProfile.id
? t('circleSettings.you')
: ''}{' '}
<Chip>
{' '}
{member.isActive ? member.role : 'Pending Approval'}
{member.isActive
? roleLabel(member.role)
: t('circleSettings.pendingApproval')}
</Chip>
</Typography>
{member.isActive ? (
<Typography level='body-sm'>
Joined on {fmt.date(member.createdAt)}
{t('circleSettings.joinedOn', {
date: fmt.date(member.createdAt),
})}
</Typography>
) : (
<Typography level='body-sm' color='danger'>
Request to join {fmt.date(member.updatedAt)}
{t('circleSettings.requestedToJoin', {
date: fmt.date(member.updatedAt),
})}
</Typography>
)}
</Box>
@@ -273,10 +305,7 @@ const CircleSettings = () => {
sx={{ mr: 1 }}
value={member.role}
renderValue={() => (
<Typography>
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</Typography>
<Typography>{roleLabel(member.role)}</Typography>
)}
onChange={(e, value) => {
UpdateMemberRole(member.userId, value).then(resp => {
@@ -291,27 +320,13 @@ const CircleSettings = () => {
} else {
showNotification({
type: 'error',
message: 'Failed to update role',
message: t('circleSettings.roleUpdateFailed'),
})
}
})
}}
>
{[
{
value: 'member',
description: 'Just a regular member of the circle',
},
{
value: 'manager',
description:
'Can impersonate users and perform actions on their behalf',
},
{
value: 'admin',
description: 'Full access to the circle',
},
].map((option, index) => (
{roleOptions.map((option, index) => (
<Option value={option.value} key={index}>
<Box
sx={{
@@ -327,8 +342,7 @@ const CircleSettings = () => {
level='title-sm'
sx={{ mb: 0, mt: 0, lineHeight: 1.1 }}
>
{option.value.charAt(0).toUpperCase() +
option.value.slice(1)}
{option.label}
</Typography>
<Typography
level='body-sm'
@@ -350,8 +364,10 @@ const CircleSettings = () => {
size='sm'
onClick={() => {
showConfirmation(
`Are you sure you want to remove ${member.displayName} from your circle?`,
'Remove Member',
t('circleSettings.removeMemberMessage', {
name: member.displayName,
}),
t('circleSettings.removeMemberTitle'),
() => {
DeleteCircleMember(
member.circleId,
@@ -360,7 +376,7 @@ const CircleSettings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Removed member successfully',
message: t('circleSettings.memberRemoved'),
})
queryClient.invalidateQueries(['circleMembers'])
queryClient.invalidateQueries(['userCircle'])
@@ -374,8 +390,8 @@ const CircleSettings = () => {
}
})
},
'Remove',
'Cancel',
t('common.remove'),
t('common.cancel'),
'danger',
)
}}
@@ -396,11 +412,15 @@ const CircleSettings = () => {
mb: 1,
}}
>
<Typography level='title-md'>Circle Member Requests</Typography>
<Typography level='title-md'>
{t('circleSettings.circleMemberRequests')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{lastRefresh && (
<Typography level='body-sm' color='neutral'>
Last updated: {fmt.dateTime(lastRefresh)}
{t('circleSettings.lastUpdated', {
time: fmt.dateTime(lastRefresh),
})}
</Typography>
)}
<Button
@@ -412,7 +432,9 @@ const CircleSettings = () => {
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
}
>
{isRefreshing ? 'Refreshing...' : 'Refresh'}
{isRefreshing
? t('circleSettings.refreshing')
: t('common.refresh')}
</Button>
</Box>
</Box>
@@ -420,21 +442,24 @@ const CircleSettings = () => {
{circleMemberRequests.map(request => (
<Card key={request.id} className='p-4'>
<Typography level='body-md'>
{request.displayName} wants to join your circle.
{t('circleSettings.wantsToJoin', { name: request.displayName })}
</Typography>
<Button
variant='soft'
color='success'
onClick={() => {
showConfirmation(
`Are you sure you want to accept ${request.displayName} (username: ${request.username}) to join your circle?`,
'Accept Member Request',
t('circleSettings.acceptRequestMessage', {
name: request.displayName,
username: request.username,
}),
t('circleSettings.acceptRequestTitle'),
() => {
AcceptCircleMemberRequest(request.id).then(resp => {
if (resp.ok) {
showNotification({
type: 'success',
message: 'Accepted request successfully',
message: t('circleSettings.requestAccepted'),
})
queryClient.invalidateQueries(['circleMembers'])
queryClient.invalidateQueries(['circleMemberRequests'])
@@ -449,26 +474,25 @@ const CircleSettings = () => {
}
})
},
'Accept',
'Cancel',
t('circleSettings.accept'),
t('common.cancel'),
)
}}
>
Accept
{t('circleSettings.accept')}
</Button>
</Card>
))}
<Divider> or </Divider>
<Divider> {t('circleSettings.or')} </Divider>
<Typography level='body-md'>
if want to join someone else's Circle? Ask them for their unique
Circle code or join link. Enter the code below to join their Circle.
{t('circleSettings.joinOtherDescription')}
</Typography>
<Typography level='title-sm' mb={-1}>
Enter Circle code:
{t('circleSettings.enterCircleCode')}
<Input
placeholder='Enter code'
placeholder={t('circleSettings.enterCodePlaceholder')}
value={circleInviteCode}
onChange={e => setCircleInviteCode(e.target.value)}
size='lg'
@@ -484,20 +508,19 @@ const CircleSettings = () => {
if (resp.ok) {
showNotification({
type: 'success',
message:
'Joined circle successfully, wait for the circle owner to accept your request.',
message: t('circleSettings.joinedPending'),
})
setTimeout(() => navigate('/'), 3000)
} else {
if (resp.status === 409) {
showNotification({
type: 'error',
message: 'You are already a member of this circle',
message: t('circleSettings.alreadyMember'),
})
} else {
showNotification({
type: 'error',
message: 'Failed to join circle',
message: t('circleSettings.joinFailed'),
})
}
setTimeout(() => navigate('/'), 3000)
@@ -505,7 +528,7 @@ const CircleSettings = () => {
})
}}
>
Join Circle
{t('circleSettings.joinCircle')}
</Button>
</Typography>
</div>

View File

@@ -44,7 +44,7 @@ const LocalizationSettings = () => {
]
return (
<SettingsLayout title='Localization'>
<SettingsLayout title={t('localization.title')}>
<div className='grid gap-4 py-4'>
<Typography level='body-md'>{t('localization.description')}</Typography>
@@ -71,12 +71,7 @@ const LocalizationSettings = () => {
))}
</Select>
{isRTL && (
<FormHelperText>
{t(
'localization.rtlNotice',
'This language uses right-to-left (RTL) text direction',
)}
</FormHelperText>
<FormHelperText>{t('localization.rtlNotice')}</FormHelperText>
)}
</FormControl>
@@ -110,7 +105,9 @@ const LocalizationSettings = () => {
))}
</Select>
<FormHelperText>
Preview: {sampleDate.format(dateFormat)}
{t('localization.preview', {
value: sampleDate.format(dateFormat),
})}
</FormHelperText>
</FormControl>
@@ -157,7 +154,9 @@ const LocalizationSettings = () => {
</Option>
</Select>
<FormHelperText>
Preview: {sampleDate.format(timeFormat)}
{t('localization.preview', {
value: sampleDate.format(timeFormat),
})}
</FormHelperText>
</FormControl>

View File

@@ -2,6 +2,8 @@ import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
import QRCode from 'qrcode'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppModal from '../../components/common/AppModal'
import ModalActions from '../../components/common/ModalActions'
import {
@@ -14,6 +16,7 @@ import LoadingComponent from '../components/Loading'
import SettingsLayout from './SettingsLayout'
const MFASettings = () => {
const { t } = useTranslation('settings')
const [mfaEnabled, setMfaEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [setupModalOpen, setSetupModalOpen] = useState(false)
@@ -56,7 +59,7 @@ const MFASettings = () => {
setQrCodeDataUrl(qrCodeDataUrl)
} catch (error) {
console.error('Error generating QR code:', error)
setError('Failed to generate QR code')
setError(t('mfa.errors.qrGenerationFailed'))
}
}
@@ -79,7 +82,7 @@ const MFASettings = () => {
hasQrCodeUrl: !!data.qrCodeUrl,
hasSecret: !!data.secret,
})
setError('Invalid response from server. Missing QR code or secret.')
setError(t('mfa.errors.invalidResponse'))
return
}
if (data.backupCodes) {
@@ -98,24 +101,22 @@ const MFASettings = () => {
} else {
// Handle different error status codes
if (response.status === 404) {
setError(
'MFA setup endpoint not found. This feature may not be available yet.',
)
setError(t('mfa.errors.notFound'))
} else if (response.status === 401) {
setError('Unauthorized. Please login again.')
setError(t('mfa.errors.unauthorized'))
} else if (response.status === 500) {
setError('Server error. Please try again later.')
setError(t('mfa.errors.serverError'))
} else {
const errorData = await response.json().catch(() => ({}))
setError(
errorData.message ||
`Failed to setup MFA (${response.status}). Please try again.`,
t('mfa.errors.setupFailed', { status: response.status }),
)
}
}
} catch (error) {
console.error('Error setting up MFA:', error)
setError('Network error. Please check your connection and try again.')
setError(t('mfa.errors.networkError'))
}
}
@@ -130,12 +131,12 @@ const MFASettings = () => {
if (response.ok) {
setSetupStep(3)
setMfaEnabled(true)
setSuccess('MFA has been successfully enabled!')
setSuccess(t('mfa.enabledSuccess'))
} else {
setError('Invalid verification code. Please try again.')
setError(t('mfa.errors.invalidCode'))
}
} catch (error) {
setError('Failed to confirm MFA. Please try again.')
setError(t('mfa.errors.confirmFailed'))
console.error('Error confirming MFA:', error)
}
}
@@ -148,12 +149,12 @@ const MFASettings = () => {
setMfaEnabled(false)
setDisableModalOpen(false)
setDisableCode('')
setSuccess('MFA has been disabled successfully!')
setSuccess(t('mfa.disabledSuccess'))
} else {
setError('Invalid verification code. Please try again.')
setError(t('mfa.errors.invalidCode'))
}
} catch (error) {
setError('Failed to disable MFA. Please try again.')
setError(t('mfa.errors.disableFailed'))
console.error('Error disabling MFA:', error)
}
}
@@ -178,14 +179,9 @@ const MFASettings = () => {
}
return (
<SettingsLayout title='Multi-Factor Authentication'>
<SettingsLayout title={t('mfa.title')}>
<div className='grid gap-4 py-4' id='mfa'>
<Typography level='body-md'>
Add an extra layer of security to your account with multi-factor
authentication (MFA). When enabled, you&apos;ll need to provide a
verification code from your authenticator app in addition to your
password when signing in.
</Typography>
<Typography level='body-md'>{t('mfa.description')}</Typography>
{success && (
<Alert color='success' onClose={() => setSuccess('')}>
@@ -204,13 +200,11 @@ const MFASettings = () => {
<Box className='flex items-center gap-3'>
<Security color='primary' />
<Box>
<Typography level='title-md'>
Two-Factor Authentication
</Typography>
<Typography level='title-md'>{t('mfa.twoFactor')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{mfaEnabled
? 'Your account is protected with 2FA'
: 'Secure your account with an authenticator app'}
? t('mfa.enabledSubtitle')
: t('mfa.disabledSubtitle')}
</Typography>
</Box>
</Box>
@@ -221,7 +215,7 @@ const MFASettings = () => {
variant='outlined'
onClick={() => setDisableModalOpen(true)}
>
Disable
{t('mfa.disable')}
</Button>
) : (
<Button
@@ -229,7 +223,7 @@ const MFASettings = () => {
variant='solid'
onClick={handleSetupMFA}
>
Enable
{t('mfa.enable')}
</Button>
)}
</Box>
@@ -265,23 +259,29 @@ const MFASettings = () => {
<AppModal
open={setupModalOpen}
onClose={closeSetupModal}
title='Set up Multi-Factor Authentication'
title={t('mfa.setup.title')}
size='md'
footer={
setupStep === 1 ? (
<ModalActions
secondary={{ label: 'Cancel', onClick: closeSetupModal }}
secondary={{
label: t('common.cancel'),
onClick: closeSetupModal,
}}
primary={{
label: "I've added the account",
label: t('mfa.setup.addedAccount'),
onClick: () => setSetupStep(2),
startDecorator: <Smartphone />,
}}
/>
) : setupStep === 2 ? (
<ModalActions
secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
secondary={{
label: t('mfa.setup.back'),
onClick: () => setSetupStep(1),
}}
primary={{
label: 'Verify & Enable',
label: t('mfa.setup.verifyAndEnable'),
onClick: handleConfirmMFA,
disabled: verificationCode.length !== 6,
}}
@@ -289,7 +289,7 @@ const MFASettings = () => {
) : (
<ModalActions
primary={{
label: "I've saved my backup codes",
label: t('mfa.setup.savedBackupCodes'),
onClick: closeSetupModal,
}}
/>
@@ -299,8 +299,8 @@ const MFASettings = () => {
{setupStep === 1 && setupData && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 1:</strong> Scan the QR code below with your
authenticator app (Google Authenticator, Authy, etc.)
<strong>{t('mfa.setup.step1Label')}</strong>{' '}
{t('mfa.setup.step1')}
</Typography>
<Box className='flex justify-center rounded bg-white p-4'>
@@ -310,14 +310,11 @@ const MFASettings = () => {
qrCodeDataUrl ||
`data:image/png;base64,${setupData.qrCode}`
}
alt='MFA QR Code'
alt={t('mfa.setup.qrAlt')}
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
) : (
<Alert color='danger'>
QR code could not be generated. Please try again or use the
manual entry key below.
</Alert>
<Alert color='danger'>{t('mfa.setup.qrFailed')}</Alert>
)}
</Box>
@@ -331,7 +328,7 @@ const MFASettings = () => {
}}
>
<Typography level='title-sm'>
<strong>Manual entry key:</strong>
<strong>{t('mfa.setup.manualKey')}</strong>
</Typography>
<Typography
level='body-sm'
@@ -346,12 +343,12 @@ const MFASettings = () => {
{setupStep === 2 && (
<Stack spacing={3}>
<Typography level='body-md'>
<strong>Step 2:</strong> Enter the 6-digit verification code
from your authenticator app
<strong>{t('mfa.setup.step2Label')}</strong>{' '}
{t('mfa.setup.step2')}
</Typography>
<Input
placeholder='Enter 6-digit code'
placeholder={t('mfa.setup.codePlaceholder')}
value={verificationCode}
size='lg'
// send on enter:
@@ -383,17 +380,16 @@ const MFASettings = () => {
<Box className='text-center'>
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
<Typography level='h4' color='success'>
MFA Successfully Enabled!
{t('mfa.setup.successTitle')}
</Typography>
</Box>
<Alert color='warning'>
<Typography level='title-sm' sx={{ mb: 1 }}>
Save these backup codes in a safe place
{t('mfa.setup.backupCodesTitle')}
</Typography>
<Typography level='body-sm'>
You can use these codes to access your account if you lose
your authenticator device. Each code can only be used once.
{t('mfa.setup.backupCodesDescription')}
</Typography>
</Alert>
@@ -418,15 +414,18 @@ const MFASettings = () => {
<AppModal
open={disableModalOpen}
onClose={closeDisableModal}
title='Disable Multi-Factor Authentication'
title={t('mfa.disableModal.title')}
size='sm'
role='alertdialog'
closeOnBackdrop={false}
footer={
<ModalActions
secondary={{ label: 'Cancel', onClick: closeDisableModal }}
secondary={{
label: t('common.cancel'),
onClick: closeDisableModal,
}}
primary={{
label: 'Disable MFA',
label: t('mfa.disableModal.confirm'),
color: 'danger',
onClick: handleDisableMFA,
disabled: disableCode.length !== 6,
@@ -437,17 +436,16 @@ const MFASettings = () => {
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Disabling MFA will make your account less secure. Are you sure
you want to continue?
{t('mfa.disableModal.warning')}
</Typography>
</Alert>
<Typography level='body-md'>
Enter a verification code from your authenticator app to confirm:
{t('mfa.disableModal.prompt')}
</Typography>
<Input
placeholder='Enter 6-digit code'
placeholder={t('mfa.setup.codePlaceholder')}
value={disableCode}
size='lg'
onKeyDown={e => {
@@ -477,12 +475,12 @@ const MFASettings = () => {
<AppModal
open={backupCodesModalOpen}
onClose={() => setBackupCodesModalOpen(false)}
title='New Backup Codes'
title={t('mfa.backupCodesModal.title')}
size='sm'
footer={
<ModalActions
primary={{
label: "I've saved my backup codes",
label: t('mfa.setup.savedBackupCodes'),
onClick: () => setBackupCodesModalOpen(false),
}}
/>
@@ -491,8 +489,7 @@ const MFASettings = () => {
<Stack spacing={3}>
<Alert color='warning'>
<Typography level='body-sm'>
Your previous backup codes are now invalid. Save these new codes
in a safe place. Each code can only be used once.
{t('mfa.backupCodesModal.warning')}
</Typography>
</Alert>

View File

@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { PushNotifications } from '@capacitor/push-notifications'
import { Android, Apple } from '@mui/icons-material'
import {
Box,
@@ -18,9 +19,10 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PushNotifications } from '@capacitor/push-notifications'
import { registerPushNotifications } from '../../CapacitorListener'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useDeviceTokens, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
@@ -31,6 +33,8 @@ import {
import SettingsLayout from './SettingsLayout'
const NotificationSetting = () => {
const { t } = useTranslation('settings')
const { fmt } = useLocalization()
const { showWarning } = useNotification()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const { data: deviceTokens, refetch: refetchDevices } = useDeviceTokens()
@@ -149,26 +153,23 @@ const NotificationSetting = () => {
const handleDeviceRegistered = () => {
refetchDevices()
showWarning({
title: 'Success',
message: 'Device registered successfully for push notifications.',
title: t('common.success'),
message: t('notifications.deviceRegistered'),
})
}
const handleDeviceRegistrationFailed = event => {
const { status, error } = event.detail || {}
const { error, status } = event.detail || {}
if (status === 409) {
showWarning({
title: 'Device Limit Reached',
message:
'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
title: t('notifications.deviceLimitTitle'),
message: t('notifications.deviceLimitMessage'),
})
} else {
showWarning({
title: 'Registration Failed',
message:
error ||
'Failed to register device automatically. Please try again.',
title: t('notifications.registrationFailedTitle'),
message: error || t('notifications.registrationFailedMessage'),
})
}
}
@@ -195,16 +196,16 @@ const NotificationSetting = () => {
switch (notificationTarget) {
case '1':
if (chatID === '') {
setError('Chat ID is required')
setError(t('notifications.chatIdRequired'))
return false
} else if (isNaN(chatID) || chatID === '0') {
setError('Invalid Chat ID')
setError(t('notifications.chatIdInvalid'))
return false
}
break
case '2':
if (chatID === '') {
setError('User key is required')
setError(t('notifications.userKeyRequired'))
return false
}
break
@@ -222,12 +223,12 @@ const NotificationSetting = () => {
type: Number(notificationTarget),
}).then(resp => {
if (resp.status != 200) {
alert(`Error while updating notification target: ${resp.statusText}`)
alert(t('notifications.targetUpdateFailed', { error: resp.statusText }))
return
}
refetchUserProfile()
alert('Notification target updated')
alert(t('notifications.targetUpdated'))
})
}
@@ -238,9 +239,8 @@ const NotificationSetting = () => {
const currentDeviceCount = deviceTokens ? deviceTokens.length : 0
if (currentDeviceCount >= 5) {
showWarning({
title: 'Device Limit Reached',
message:
'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
title: t('notifications.deviceLimitTitle'),
message: t('notifications.deviceLimitMessage'),
})
return
}
@@ -251,9 +251,8 @@ const NotificationSetting = () => {
if (permStatus.receive !== 'granted') {
showWarning({
title: 'Permission Required',
message:
'Push notification permission is required to register this device.',
title: t('notifications.permissionRequiredTitle'),
message: t('notifications.permissionRequiredMessage'),
})
return
}
@@ -267,25 +266,25 @@ const NotificationSetting = () => {
setPushNotification(true)
showWarning({
title: 'Registration Initiated',
message:
'Push notification registration has been initiated. The device will be registered automatically.',
title: t('notifications.registrationInitiatedTitle'),
message: t('notifications.registrationInitiatedMessage'),
})
} catch (error) {
console.error('Error registering device:', error)
showWarning({
title: 'Error',
message: 'Failed to register device. Please try again.',
title: t('common.error'),
message: t('notifications.registerDeviceFailed'),
})
}
}
return (
<SettingsLayout title='Notification Settings'>
<SettingsLayout title={t('notifications.title')}>
<div className='grid gap-4 py-4' id='notifications'>
<Typography level='h3'>Device Notification</Typography>
<Typography level='h3'>{t('notifications.deviceSection')}</Typography>
<Divider />
<Typography level='body-md'>Manage your Device Notification</Typography>
<Typography level='body-md'>
{t('notifications.deviceSectionDescription')}
</Typography>
<FormControl orientation='horizontal'>
<Switch
@@ -300,9 +299,8 @@ const NotificationSetting = () => {
setNotificationPreferences({ granted: true })
} else if (resp.display === 'denied') {
showWarning({
title: 'Notification Permission Denied',
message:
'You have denied notification permissions. You can enable them later in your device settings.',
title: t('notifications.permissionDeniedTitle'),
message: t('notifications.permissionDeniedMessage'),
})
setDeviceNotification(false)
setNotificationPreferences({ granted: false })
@@ -324,11 +322,11 @@ const NotificationSetting = () => {
sx={{ mr: 2 }}
/>
<div>
<FormLabel>Device Notification</FormLabel>
<FormLabel>{t('notifications.deviceLabel')}</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{Capacitor.isNativePlatform()
? 'Receive notification on your device when a task is due'
: 'This feature is only available on mobile devices'}{' '}
? t('notifications.deviceHelper')
: t('notifications.mobileOnly')}{' '}
</FormHelperText>
</div>
</FormControl>
@@ -345,8 +343,8 @@ const NotificationSetting = () => {
LocalNotifications.schedule({
notifications: [
{
title: 'Test Notification',
body: 'You have a task due soon',
title: t('notifications.testNotification'),
body: t('notifications.testNotificationBody'),
id: 1,
schedule: { at: new Date(Date.now() + 2000) },
sound: null,
@@ -358,32 +356,32 @@ const NotificationSetting = () => {
})
}}
>
Test Notification{' '}
{t('notifications.testNotification')}{' '}
</Button>
{deviceNotification && (
<Card>
{[
{
title: 'Due Date Notification',
title: t('notifications.dueTitle'),
checked: dueNotification,
set: setDueNotification,
label: 'Notification when the task is due',
label: t('notifications.dueLabel'),
property: 'dueNotification',
disabled: false,
},
{
title: 'Pre-Due Date Notification',
title: t('notifications.preDueTitle'),
checked: preDueNotification,
set: setPreDueNotification,
label: 'Notification a few hours before the task is due',
label: t('notifications.preDueLabel'),
property: 'preDueNotification',
disabled: false,
},
{
title: 'Overdue Notification',
title: t('notifications.overdueTitle'),
checked: naggingNotification,
set: setNaggingNotification,
label: 'Notification when the task is overdue',
label: t('notifications.overdueLabel'),
property: 'naggingNotification',
disabled: false,
},
@@ -409,7 +407,7 @@ const NotificationSetting = () => {
}}
color={item.checked ? 'success' : ''}
variant='solid'
endDecorator={item.checked ? 'On' : 'Off'}
endDecorator={item.checked ? t('common.on') : t('common.off')}
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
/>
</FormControl>
@@ -422,11 +420,11 @@ const NotificationSetting = () => {
sx={{ width: 400, justifyContent: 'space-between' }}
>
<div>
<FormLabel>Push Notifications</FormLabel>
<FormLabel>{t('notifications.pushLabel')}</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{Capacitor.isNativePlatform()
? 'Receive Nudges, Announcements, and Chore Assignments via Push Notifications'
: 'This feature is only available on mobile devices'}{' '}
? t('notifications.pushHelper')
: t('notifications.mobileOnly')}{' '}
</FormHelperText>
</div>
<Switch
@@ -446,9 +444,8 @@ const NotificationSetting = () => {
}
if (resp.receive !== 'granted') {
showWarning({
title: 'Push Notification Permission Denied',
message:
'Push notifications have been disabled. You can enable them in your device settings if needed.',
title: t('notifications.pushPermissionDeniedTitle'),
message: t('notifications.pushPermissionDeniedMessage'),
})
setPushNotification(false)
setPushNotificationPreferences({ granted: false })
@@ -463,7 +460,7 @@ const NotificationSetting = () => {
}}
color={pushNotification ? 'success' : 'neutral'}
variant={pushNotification ? 'solid' : 'outlined'}
endDecorator={pushNotification ? 'On' : 'Off'}
endDecorator={pushNotification ? t('common.on') : t('common.off')}
slotProps={{
endDecorator: {
sx: {
@@ -478,11 +475,13 @@ const NotificationSetting = () => {
{isOfficialInstance && (
<>
<Typography level='h4' sx={{ mt: 2 }}>
Registered Devices ({deviceTokens ? deviceTokens.length : 0}/5)
{t('notifications.registeredDevices', {
count: deviceTokens ? deviceTokens.length : 0,
})}
</Typography>
<Divider />
<Typography level='body-md' sx={{ mb: 2 }}>
Devices registered to receive push notifications for your account
{t('notifications.registeredDevicesDescription')}
</Typography>
{/* Show register current device option if not registered */}
@@ -508,12 +507,16 @@ const NotificationSetting = () => {
)}
<Box>
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
Current Device:{' '}
{currentDevice.platform === 'ios' ? 'iOS' : 'Android'}{' '}
{currentDevice.model}
{t('notifications.currentDevice', {
platform:
currentDevice.platform === 'ios'
? 'iOS'
: 'Android',
model: currentDevice.model,
})}
</Typography>
<Typography level='body-sm' color='neutral'>
This device is not registered for push notifications
{t('notifications.currentDeviceNotRegistered')}
</Typography>
</Box>
</Box>
@@ -525,8 +528,8 @@ const NotificationSetting = () => {
onClick={handleRegisterCurrentDevice}
>
{deviceTokens && deviceTokens.length >= 5
? 'Limit Reached'
: 'Register Device'}
? t('notifications.limitReached')
: t('notifications.registerDevice')}
</Button>
</Box>
</Card>
@@ -557,13 +560,15 @@ const NotificationSetting = () => {
sx={{ fontWeight: 'bold' }}
>
{device.platform === 'ios' ? 'iOS' : 'Android'}{' '}
{device.deviceModel || 'Unknown Device'}
{device.deviceModel ||
t('notifications.unknownDevice')}
</Typography>
{device.createdAt && (
<Typography level='body-sm' color='neutral'>
Created At:{' '}
{new Date(device.createdAt).toLocaleDateString()}
{t('notifications.deviceCreatedAt', {
date: fmt.date(device.createdAt),
})}
</Typography>
)}
</Box>
@@ -582,19 +587,19 @@ const NotificationSetting = () => {
refetchDevices()
} else {
showWarning({
title: 'Error',
message: 'Failed to unregister device',
title: t('common.error'),
message: t('notifications.unregisterFailed'),
})
}
} catch (error) {
showWarning({
title: 'Error',
message: 'Failed to unregister device',
title: t('common.error'),
message: t('notifications.unregisterFailed'),
})
}
}}
>
Remove
{t('common.remove')}
</Button>
</Box>
</Card>
@@ -602,16 +607,16 @@ const NotificationSetting = () => {
</Box>
) : (
<Typography level='body-md' color='neutral'>
No devices registered for push notifications
{t('notifications.noDevices')}
</Typography>
)}
</>
)}
<Typography level='h3'>Custom Notification</Typography>
<Typography level='h3'>{t('notifications.customSection')}</Typography>
<Divider />
<Typography level='body-md'>
Notification through other platform like Telegram or Pushover
{t('notifications.customSectionDescription')}
</Typography>
<FormControl orientation='horizontal'>
@@ -649,9 +654,9 @@ const NotificationSetting = () => {
sx={{ mr: 2 }}
/>
<div>
<FormLabel>Custom Notification</FormLabel>
<FormLabel>{t('notifications.customLabel')}</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
Receive notification on other platform
{t('notifications.customHelper')}
</FormHelperText>
</div>
</FormControl>
@@ -668,16 +673,15 @@ const NotificationSetting = () => {
sx={{ maxWidth: '200px' }}
onChange={(e, selected) => setNotificationTarget(selected)}
>
<Option value='0'>None</Option>
<Option value='1'>Telegram</Option>
<Option value='2'>Pushover</Option>
<Option value='3'>Webhooks</Option>
<Option value='0'>{t('notifications.targetNone')}</Option>
<Option value='1'>{t('notifications.targetTelegram')}</Option>
<Option value='2'>{t('notifications.targetPushover')}</Option>
<Option value='3'>{t('notifications.targetWebhooks')}</Option>
</Select>
{notificationTarget === '1' && (
<>
<Typography level='body-xs'>
You need to initiate a message to the bot in order for the
Telegram notification to work{' '}
{t('notifications.telegramBotHelpBefore')}{' '}
<a
style={{
textDecoration: 'underline',
@@ -685,24 +689,25 @@ const NotificationSetting = () => {
}}
href='https://t.me/DonetickBot'
>
Click here
{t('notifications.clickHere')}
</a>{' '}
to start a chat
{t('notifications.telegramBotHelpAfter')}
</Typography>
<Typography level='body-sm'>Chat ID</Typography>
<Typography level='body-sm'>
{t('notifications.chatId')}
</Typography>
<Input
value={chatID}
onChange={e => setChatID(e.target.value)}
placeholder='User ID / Chat ID'
placeholder={t('notifications.chatIdPlaceholder')}
sx={{
width: '200px',
}}
/>
<Typography mt={0} level='body-xs'>
If you don't know your Chat ID, start chat with userinfobot
and it will send you your Chat ID.{' '}
{t('notifications.telegramChatIdHelpBefore')}{' '}
<a
style={{
textDecoration: 'underline',
@@ -710,19 +715,21 @@ const NotificationSetting = () => {
}}
href='https://t.me/userinfobot'
>
Click here
{t('notifications.clickHere')}
</a>{' '}
to start chat with userinfobot{' '}
{t('notifications.telegramChatIdHelpAfter')}{' '}
</Typography>
</>
)}
{notificationTarget === '2' && (
<>
<Typography level='body-sm'>User key</Typography>
<Typography level='body-sm'>
{t('notifications.userKey')}
</Typography>
<Input
value={chatID}
onChange={e => setChatID(e.target.value)}
placeholder='User ID'
placeholder={t('notifications.userKeyPlaceholder')}
sx={{
width: '200px',
}}
@@ -742,7 +749,7 @@ const NotificationSetting = () => {
}}
onClick={handleSave}
>
Save
{t('common.save')}
</Button>
</Box>
)}

View File

@@ -180,7 +180,7 @@ const ProfileSettings = () => {
setShowCropper(false)
setSelectedFile(null)
}}
title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
title={t('profile.editPhoto')}
size='sm'
closeOnBackdrop={!isUploading}
closeOnEscape={!isUploading}

View File

@@ -280,7 +280,7 @@ const SettingsOverview = () => {
borderColor: 'warning.main',
}}
>
Early Access
{t('common.earlyAccess')}
</Chip>
)}
</Box>

View File

@@ -26,6 +26,8 @@ import {
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DEFAULT_SIDEPANEL_CONFIG,
getSidepanelConfig,
@@ -34,8 +36,15 @@ import {
import SettingsLayout from './SettingsLayout'
const SidepanelSettings = () => {
const { t } = useTranslation('settings')
const [config, setConfig] = useState(getSidepanelConfig())
// Card names/descriptions live in the config so they can be persisted, but the
// stored copy is English. Prefer the translated string and fall back to it.
const cardName = item => t(`sidepanel.cards.${item.id}.name`, item.name)
const cardDescription = item =>
t(`sidepanel.cards.${item.id}.description`, item.description)
const getIcon = iconName => {
switch (iconName) {
case 'SupervisorAccount':
@@ -93,15 +102,14 @@ const SidepanelSettings = () => {
}
return (
<SettingsLayout title='Sidepanel Customization'>
<SettingsLayout title={t('sidepanel.title')}>
<div className='grid gap-4'>
<Box>
<Typography level='h4' sx={{ mb: 2 }}>
Sidepanel Settings
{t('sidepanel.heading')}
</Typography>
<Typography level='body-md' sx={{ mb: 3 }}>
Customize which cards appear in the sidepanel and their order. Drag
and drop to reorder, or toggle visibility for each card.
{t('sidepanel.description')}
</Typography>
<DragDropContext onDragEnd={handleDragEnd}>
@@ -176,7 +184,7 @@ const SidepanelSettings = () => {
level='title-sm'
sx={{ fontWeight: 600 }}
>
{item.name}
{cardName(item)}
</Typography>
<Typography
level='body-xs'
@@ -184,7 +192,7 @@ const SidepanelSettings = () => {
color: 'var(--joy-palette-text-tertiary)',
}}
>
- {item.description}
- {cardDescription(item)}
</Typography>
</Box>
</ListItemContent>
@@ -226,10 +234,10 @@ const SidepanelSettings = () => {
onClick={resetToDefaults}
size='sm'
>
Reset to Defaults
{t('sidepanel.resetToDefaults')}
</Button>
<FormHelperText sx={{ mt: 1 }}>
This will restore all cards to their default visibility and order.
{t('sidepanel.resetHelper')}
</FormHelperText>
</Box>
</Box>

View File

@@ -1,13 +1,9 @@
import { Capacitor } from '@capacitor/core'
import {
Button,
Card,
Chip,
LinearProgress,
Typography,
} from '@mui/joy'
import { Button, Card, Chip, LinearProgress, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { GetStorageUsage } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
@@ -15,6 +11,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import SettingsLayout from './SettingsLayout'
const StorageSettings = () => {
const { t } = useTranslation('settings')
const Navigate = useNavigate()
const { data: userProfile } = useUserProfile()
const [usage, setUsage] = useState({ used: 0, total: 0 })
@@ -25,8 +22,8 @@ const StorageSettings = () => {
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmText = t('common.confirm'),
cancelText = t('common.cancel'),
color = 'primary',
) => {
setConfirmModalConfig({
@@ -62,20 +59,19 @@ const StorageSettings = () => {
const totalMB = (usage.total / (1024 * 1024)).toFixed(2)
return (
<SettingsLayout title='Storage Settings'>
<SettingsLayout title={t('storage.title')}>
<div className='grid gap-4 py-4' id='storage'>
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
Server Storage Usage
{t('storage.serverTitle')}
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
{t('common.plusFeature')}
</Chip>
)}
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is the storage used by your account on our servers (e.g. files,
images, and data you have uploaded).
{t('storage.serverDescription')}
</Typography>
{!isPlusAccount(userProfile) ? (
<>
@@ -91,23 +87,26 @@ const StorageSettings = () => {
}}
/>
<Typography level='body-xs' sx={{ opacity: 0.6, mb: 1 }}>
-- MB used / -- MB total (--)
{t('storage.usagePlaceholder')}
</Typography>
<Typography level='body-sm' color='warning'>
Server storage is not available in the Basic plan. Upgrade to
Plus to track your server storage usage.
{t('storage.basicPlanNotice')}
</Typography>
</>
) : loading ? (
<>
<LinearProgress sx={{ mb: 1 }} />
<Typography level='body-xs'>Loading...</Typography>
<Typography level='body-xs'>{t('common.loading')}</Typography>
</>
) : (
<>
<LinearProgress determinate value={percent} sx={{ mb: 1 }} />
<Typography level='body-xs'>
{usedMB} MB used / {totalMB} MB total ({percent}%)
{t('storage.usage', {
used: usedMB,
total: totalMB,
percent,
})}
</Typography>
</>
)}
@@ -115,72 +114,69 @@ const StorageSettings = () => {
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
Cache
{Capacitor.isNativePlatform()
? t('storage.localTitleApp')
: t('storage.localTitleBrowser')}
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
This is data stored locally in your browser for faster access.
Clearing this will not affect your server data, but may log you out.
{t('storage.localDescription')}
</Typography>
<Button
variant='soft'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.',
'Clear All Local Storage',
t('storage.clearLocalMessage'),
t('storage.clearLocalTitle'),
() => {
localStorage.clear()
Navigate('/login')
},
'Clear All',
'Cancel',
t('storage.clearAll'),
t('common.cancel'),
'danger',
)
}}
>
Clear All Local Storage and Cache
{t('storage.clearLocal')}
</Button>
</Card>
{Capacitor.isNativePlatform() && (
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
App Preferences
{t('storage.appPreferences')}
<Chip variant='soft' color='info' sx={{ ml: 1 }}>
Device Only
{t('storage.deviceOnly')}
</Chip>
</Typography>
<Typography level='body-sm' sx={{ mb: 1 }}>
These are preferences and settings stored locally on your device
by the app. Clearing them will reset app-specific settings and may
log you out, but will not affect your server data.
{t('storage.appPreferencesDescription')}
</Typography>
<Button
variant='soft'
color='danger'
onClick={() => {
showConfirmation(
'Are you sure you want to clear all app preferences? This will reset your app settings and may require you to log in again.',
'Clear App Preferences',
t('storage.clearPreferencesMessage'),
t('storage.clearPreferencesTitle'),
async () => {
try {
const { Preferences } = await import(
'@capacitor/preferences'
)
const { Preferences } =
await import('@capacitor/preferences')
await Preferences.clear()
Navigate('/login')
} catch (e) {
// Optionally show error feedback
}
},
'Clear Preferences',
'Cancel',
t('storage.clearPreferences'),
t('common.cancel'),
'danger',
)
}}
>
Clear App Preferences
{t('storage.clearPreferences')}
</Button>
</Card>
)}

View File

@@ -1,19 +1,20 @@
import { Typography } from '@mui/joy'
import { useTranslation } from 'react-i18next'
import SettingsLayout from './SettingsLayout'
import ThemeToggle from './ThemeToggle'
const ThemeSettings = () => {
const { t } = useTranslation('settings')
return (
<SettingsLayout title="Theme Preferences">
<SettingsLayout title={t('theme.title')}>
<div className='grid gap-4'>
<Typography level='body-md'>
Choose how the site looks to you. Select a single theme, or sync with
your system and automatically switch between day and night themes.
</Typography>
<Typography level='body-md'>{t('theme.description')}</Typography>
<ThemeToggle />
</div>
</SettingsLayout>
)
}
export default ThemeSettings
export default ThemeSettings

View File

@@ -1075,8 +1075,12 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
}
if (!frequency && dueDate) {
// Use RFC3339/ISO-8601 format expected by backend.
if (dueDate) {
// Use RFC3339/ISO-8601 format expected by backend. The backend only
// derives NextDueDate from what's sent on create (handler.go never
// computes it from frequencyType), so this must be sent whether or
// not the task also repeats — otherwise a recurring task created with
// a due date lands with nextDueDate: null.
chore.nextDueDate = new Date(dueDate).toISOString()
}
if (hasReminders && (frequency || dueDate)) {

View File

@@ -42,7 +42,6 @@ import SyncStatusIndicator from './SyncStatusIndicator'
const publicPages = ['/landing', '/privacy', '/terms']
const NavBar = () => {
const { t } = useTranslation('common')
const { isRTL } = useLocalization()
const { data: resource } = useResource()
const { openSearch } = useGlobalSearch()
@@ -126,7 +125,7 @@ const NavBar = () => {
aria-label='Back from search'
title={t('back')}
>
<ArrowBack />
<ArrowBack className='rtl-flip' />
</IconButton>
)
}
@@ -158,7 +157,7 @@ const NavBar = () => {
: t('back')
}
>
<ArrowBack />
<ArrowBack className='rtl-flip' />
</IconButton>
)
}
@@ -226,14 +225,19 @@ const NavBar = () => {
<Drawer
open={drawerOpen}
onClose={closeDrawer}
anchor={isRTL ? 'right' : 'left'}
// Always 'left'. Joy bakes the anchor into emotion CSS (`left: 0` plus a
// translateX for the slide), so stylis-plugin-rtl already mirrors it to
// the right edge under RTL. Branching on isRTL here would flip it twice
// and land the drawer back on the left, half off-screen.
anchor='left'
size='sm'
onClick={closeDrawer}
sx={{
'& .MuiDrawer-content': {
position: 'fixed',
// pt: 'calc(var(--safe-area-inset-top, 0px))',
...(isRTL ? { right: 0 } : { left: 0 }),
// Physical on purpose, so it is mirrored in step with the anchor.
left: 0,
// pb: 'calc(var(--safe-area-inset-bottom, 0px))',
// height:
// 'calc(100vh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))',