feat: enhance localization across settings views
- Updated LocalizationSettings to use translation keys for titles and descriptions. - Refactored MFASettings to utilize translation for error messages, success messages, and UI text. - Improved NotificationSetting by integrating translation for all user-facing strings. - Initial Hebrew translation ( testing AI translation ( some of crowdin) - Adjusted ProfileSettings to use translation for the edit photo title. - Enhanced SettingsOverview to utilize translation for the early access label. - Updated SidepanelSettings to implement translation for card names and descriptions. - Refined StorageSettings to use translation for all storage-related messages and titles. - Improved ThemeSettings to utilize translation for theme descriptions and titles.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { Sync, SyncDisabled } from '@mui/icons-material'
|
||||
import { Box, Card, Chip, FormHelperText, Switch, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useSSEContext } from '../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
@@ -12,6 +14,7 @@ const REALTIME_TYPES = {
|
||||
}
|
||||
|
||||
const RealTimeSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
// SSE context
|
||||
@@ -63,26 +66,26 @@ const RealTimeSettings = () => {
|
||||
|
||||
const getStatusDescription = () => {
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.'
|
||||
return t('advanced.realtime.basicPlan')
|
||||
}
|
||||
|
||||
if (realtimeType === REALTIME_TYPES.DISABLED) {
|
||||
return 'Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.'
|
||||
return t('advanced.realtime.disabled')
|
||||
}
|
||||
|
||||
if (context.isConnected) {
|
||||
return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks."
|
||||
return t('advanced.realtime.connected')
|
||||
}
|
||||
|
||||
if (context.isConnecting) {
|
||||
return 'Connecting to real-time updates...'
|
||||
return t('advanced.realtime.connecting')
|
||||
}
|
||||
|
||||
if (context.error) {
|
||||
return `Real-time updates are enabled but not working: ${context.error}`
|
||||
return t('advanced.realtime.errored', { error: context.error })
|
||||
}
|
||||
|
||||
return 'Real-time updates are enabled but not currently connected.'
|
||||
return t('advanced.realtime.notConnected')
|
||||
}
|
||||
|
||||
const getConnectionStatusComponent = () => {
|
||||
@@ -109,7 +112,7 @@ const RealTimeSettings = () => {
|
||||
realtimeType !== REALTIME_TYPES.DISABLED ? 'success' : 'neutral'
|
||||
}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
|
||||
inputProps={{ 'aria-label': t('advanced.realtime.toggleLabel') }}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
@@ -121,10 +124,10 @@ const RealTimeSettings = () => {
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>
|
||||
Real-time Updates
|
||||
{t('advanced.realtime.title')}
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
{t('common.plusFeature')}
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
@@ -137,7 +140,7 @@ const RealTimeSettings = () => {
|
||||
)}
|
||||
</Box>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Get instant notifications when tasks are updated
|
||||
{t('advanced.realtime.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -167,7 +170,7 @@ const RealTimeSettings = () => {
|
||||
isPlusAccount(userProfile) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Status:
|
||||
{t('advanced.realtime.statusLabel')}
|
||||
</Typography>
|
||||
{getConnectionStatusComponent()}
|
||||
{context.error && (
|
||||
@@ -180,9 +183,7 @@ const RealTimeSettings = () => {
|
||||
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||
Real-time updates are not available in the Basic plan. Upgrade to Plus
|
||||
to receive instant notifications when you or other circle members
|
||||
complete, skip, or modify tasks.
|
||||
{t('advanced.realtime.basicPlanNotice')}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import useStickyState from '@/hooks/useStickyState'
|
||||
import 'moment/locale/he'
|
||||
|
||||
import moment from 'moment'
|
||||
import { createContext, useContext, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import useStickyState from '@/hooks/useStickyState'
|
||||
|
||||
const LocalizationContext = createContext()
|
||||
|
||||
export const DATE_FORMATS = {
|
||||
@@ -25,6 +28,7 @@ export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'he', name: 'Hebrew', nativeName: 'עברית' },
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
@@ -53,7 +57,10 @@ export const LocalizationProvider = ({ children }) => {
|
||||
}, [language, i18n])
|
||||
|
||||
useEffect(() => {
|
||||
const isRTL = RTL_LANGUAGES.includes(language)
|
||||
const isRTL = RTL_LANGUAGES.some(
|
||||
rtlLanguage =>
|
||||
language === rtlLanguage || language.startsWith(`${rtlLanguage}-`),
|
||||
)
|
||||
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
|
||||
document.documentElement.lang = language
|
||||
}, [language])
|
||||
@@ -91,7 +98,10 @@ export const LocalizationProvider = ({ children }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isRTL = RTL_LANGUAGES.includes(language)
|
||||
const isRTL = RTL_LANGUAGES.some(
|
||||
rtlLanguage =>
|
||||
language === rtlLanguage || language.startsWith(`${rtlLanguage}-`),
|
||||
)
|
||||
|
||||
const fmt = {
|
||||
date: formatDate,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -373,7 +373,7 @@ const SettingsOverview = () => {
|
||||
borderColor: 'warning.main',
|
||||
}}
|
||||
>
|
||||
Early Access
|
||||
{t('common.earlyAccess')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user