From a9196df9f4fb89646f3a32cb078efcb71c5c24ba Mon Sep 17 00:00:00 2001 From: santichausis Date: Mon, 6 Jul 2026 23:11:49 -0300 Subject: [PATCH 1/2] frontend: Split Settings page into tabbed sub-pages The settings page had grown into a single ~560-line component mixing account info, profile, timezone, sustaining membership, MFA devices, and API keys. Split it into Profile, Security, API Keys, and Billing tabs, each in its own component, so future settings features have a natural place to live instead of growing the monolith further. Each tab only mounts (and fetches its data) when selected, instead of all four fetching eagerly on page load as before. Closes #256. --- frontend/src/components/settings/Settings.jsx | 531 +----------------- .../components/settings/SettingsApiKeys.jsx | 148 +++++ .../components/settings/SettingsBilling.jsx | 172 ++++++ .../components/settings/SettingsProfile.jsx | 187 ++++++ .../components/settings/SettingsSecurity.jsx | 98 ++++ frontend/src/locales/de/translation.json | 6 + frontend/src/locales/en/translation.json | 6 + 7 files changed, 640 insertions(+), 508 deletions(-) create mode 100644 frontend/src/components/settings/SettingsApiKeys.jsx create mode 100644 frontend/src/components/settings/SettingsBilling.jsx create mode 100644 frontend/src/components/settings/SettingsProfile.jsx create mode 100644 frontend/src/components/settings/SettingsSecurity.jsx diff --git a/frontend/src/components/settings/Settings.jsx b/frontend/src/components/settings/Settings.jsx index 8629aefd..5f463163 100644 --- a/frontend/src/components/settings/Settings.jsx +++ b/frontend/src/components/settings/Settings.jsx @@ -1,293 +1,32 @@ -import React, { useEffect, useState } from 'react'; -import { Box, Button, ButtonGroup, CircularProgress, Grid, InputLabel, LinearProgress, makeStyles, MenuItem, Paper, Select, TableContainer, Typography } from '@material-ui/core'; -import { grey } from '@material-ui/core/colors'; +import React, { useState } from 'react'; +import { Button, ButtonGroup, Tab, Tabs } from '@material-ui/core'; import { useTranslation } from 'react-i18next'; -import { createBillingPortalSession, getAPIKeys, getMFADevices, getSubscriptionLink, getUserProfile, updateUserProfile } from '../../utils/API'; -import useTimezones from '../../hooks/useTimezones'; import useUserProfile from '../../hooks/useUserProfile'; import Breadcrumbs from '../misc/Breadcrumbs'; import Heading from '../misc/Heading'; -import Title from '../misc/Title'; -import ValidatingTextField from '../misc/ValidatingTextField'; -import SaveIcon from '@material-ui/icons/Save'; import EmailIcon from '@material-ui/icons/Email'; import DeleteForeverIcon from '@material-ui/icons/DeleteForever'; -import { useSnackbar } from 'notistack'; -import { setUserProfile } from '../../redux/actions'; -import { useDispatch } from 'react-redux'; import PasswordIcon from '@material-ui/icons/LockOpen'; -import moment from 'moment'; import ChangePasswordDialog from './ChangePasswordDialog'; import ChangeEmailAddressDialog from './ChangeEmailAddressDialog'; -import CreateMFADeviceDialog from './CreateMFADeviceDialog'; -import DeleteMFADeviceDialog from './DeleteMFADeviceDialog'; -import { RegexPatterns, SubscriptionStatus } from '../../utils/Constants'; import DeleteAccountDialog from './DeleteAccountDialog'; -import ManageSubscriptionIcon from '@material-ui/icons/CreditCard'; -import SubscriptionActiveIcon from '@material-ui/icons/FavoriteBorder'; -import SubscriptionInactiveIcon from '@material-ui/icons/PauseCircleOutline'; -import LearnMoreIcon from '@material-ui/icons/Loyalty'; -import TOTPDeviceIcon from '@material-ui/icons/PhoneIphone'; -import YubicoOTPDeviceIcon from '@material-ui/icons/VpnKey'; -import ApiKeyIcon from '@material-ui/icons/VpnKey'; -import DeleteIcon from '@material-ui/icons/Delete'; -import ShowKeyIcon from '@material-ui/icons/Visibility'; -import AddIcon from '@material-ui/icons/Add'; -import DocsIcon from '@material-ui/icons/HelpOutline'; -import EditIcon from '@material-ui/icons/Edit'; -import IconAvatar from '../misc/IconAvatar'; import { Config } from '../../utils/Config'; -import SubscribeDialog from './SubscribeDialog'; -import Table from '../misc/Table'; -import ShowAPIKeyDialog from './ShowAPIKeyDialog'; -import DeleteAPIKeyDialog from './DeleteAPIKeyDialog'; -import CreateAPIKeyDialog from './CreateAPIKeyDialog'; -import EditAPIKeyDialog from './EditAPIKeyDialog'; - -const useStyles = makeStyles(theme => ({ - grid: { - '& .MuiGrid-item': { - padding: theme.spacing(2) - } - }, - paper: { - '&:not(:last-of-type)': { - marginBottom: theme.spacing(2) - } - }, - timezoneNote: { - color: grey[600] - }, - signupDate: { - paddingTop: theme.spacing(0.5) - }, - quotaIndicator: { - marginRight: theme.spacing(2) - }, - actionButton: { - margin: theme.spacing(0.5) - } -})); - -const REFRESH_INTERVAL = 2000; +import SettingsProfile from './SettingsProfile'; +import SettingsSecurity from './SettingsSecurity'; +import SettingsApiKeys from './SettingsApiKeys'; +import SettingsBilling from './SettingsBilling'; export default function Settings() { - const classes = useStyles(); const { t } = useTranslation(); - const timezones = useTimezones(); const userProfile = useUserProfile(); - const dispatch = useDispatch(); - const { enqueueSnackbar } = useSnackbar(); - const [ isLoading, setIsLoading ] = useState(true); - const [ saving, setSaving ] = useState(false); - const [ isLoadingManageSubscription, setIsLoadingManageSubscription ] = useState(false); + const [ tabValue, setTabValue ] = useState('profile'); const [ showChangePassword, setShowChangePassword ] = useState(false); const [ showChangeEmail, setShowChangeEmail ] = useState(false); const [ showDeleteAccount, setShowDeleteAccount ] = useState(false); - const [ showSubscribeDialog, setShowSubscribeDialog ] = useState(false); - - const [ isLoadingMFADevices, setIsLoadingMFADevices ] = useState(true); - const [ mfaDevices, setMFADevices ] = useState([]); - const [ showCreateMFADevice, setShowCreateMFADevice ] = useState(false); - const [ deleteMFADevice, setDeleteMFADevice ] = useState(null); - - const [ isLoadingApiKeys, setIsLoadingApiKeys ] = useState(true); - const [ apiKeys, setApiKeys ] = useState([]); - const [ showCreateApiKey, setShowCreateApiKey ] = useState(false); - const [ showApiKey, setShowApiKey ] = useState(null); - const [ deleteApiKey, setDeleteApiKey ] = useState(null); - const [ editApiKey, setEditApiKey ] = useState(null); - - const [ firstName, setFirstName ] = useState(''); - const [ lastName, setLastName ] = useState(''); - const [ timezone, setTimezone ] = useState(''); - const [ email, setEmail ] = useState(''); - const [ newsletterSubscribe, setNewsletterSubscribe ] = useState('undefined'); - const [ signupDate, setSignupDate ] = useState(0); - - useEffect(() => { - if (userProfile && userProfile.userProfile && Object.keys(userProfile.userProfile).length > 0) { - setFirstName(userProfile.userProfile.firstName); - setLastName(userProfile.userProfile.lastName); - setTimezone(userProfile.userProfile.timezone); - setEmail(userProfile.userProfile.email); - setNewsletterSubscribe(userProfile.userProfile.newsletterSubscribe); - setSignupDate(userProfile.userProfile.signupDate); - setIsLoading(false); - } - }, [userProfile]); - - function refreshMFADevices() { - setIsLoadingMFADevices(true); - getMFADevices() - .then(response => setMFADevices(response.mfaDevices)) - .finally(() => setIsLoadingMFADevices(false)); - } - - function refreshApiKeys() { - setIsLoadingApiKeys(true); - getAPIKeys() - .then(response => setApiKeys(response.apiKeys)) - .finally(() => setIsLoadingApiKeys(false)); - } - - useEffect(() => { - refreshMFADevices(); - refreshApiKeys(); - }, []); - - function saveSettings() { - setSaving(true); - - const newProfile = { - ...userProfile, - userProfile: { - ...userProfile.userProfile, - firstName, - lastName, - timezone, - newsletterSubscribe - } - }; - - updateUserProfile(newProfile.userProfile) - .then(() => { - dispatch(setUserProfile(newProfile)); - enqueueSnackbar(t('settings.saved'), { variant: 'success' }); - }) - .catch(() => enqueueSnackbar(t('settings.saveFailed'), { variant: 'error' })) - .finally(() => setSaving(false)); - } - - function manageSubscription() { - setIsLoadingManageSubscription(true); - - if (userProfile.userSubscription.type === 'stripe') { - createBillingPortalSession() - .then(respone => window.location.href = respone.url) - .catch(() => { - enqueueSnackbar(t('settings.manageSubscriptionFailed'), { variant: 'error' }); - setIsLoadingManageSubscription(false); - }); - } else if (userProfile.userSubscription.type === 'paddle') { - const wnd = window.open('', '_blank'); - - getSubscriptionLink('manage') - .then(response => { - if (wnd) { - wnd.location.href = response.url; - } else { - window.location.href = response.url; - } - }) - .catch(() => { - if (wnd) { - wnd.close(); - } - enqueueSnackbar(t('settings.manageSubscriptionFailed'), { variant: 'error' }); - }) - .finally(() => setIsLoadingManageSubscription(false)); - } - } - - const isCancelledSubscription = userProfile && userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.CANCELLED; - const isPaymentReturn = window && window.location && window.location.search === '?checkoutSuccess=true'; - useEffect(() => { - function doRefreshProfile() { - getUserProfile().then(response => dispatch(setUserProfile(response))); - } - - if (isPaymentReturn && userProfile && (!userProfile.userSubscription || userProfile.userSubscription.status !== SubscriptionStatus.ACTIVE)) { - const handle = window.setInterval(doRefreshProfile, REFRESH_INTERVAL); - return () => window.clearInterval(handle); - } - }, [isPaymentReturn, userProfile, dispatch]); - - const MFA_COLUMNS = [ - { - head: t('settings.mfa.title'), - cell: mfaDevice =>
- : mfaDevice.type===1 ? : <>} color={mfaDevice.enabled ? 'green' : 'default'} /> -
-
{mfaDevice.title}
-
- {mfaDevice.type===0 && <>{t('settings.mfa.totpDevice.title')}} - {mfaDevice.type===1 && <>{t('settings.mfa.yubicoOtpDevice.title')}} -
-
-
- }, - { - head: t('common.actions'), - cell: mfaDevice => <> - - - } - ]; - - const APIKEY_COLUMNS = [ - { - head: t('settings.apiKeys.title'), - cell: apiKey =>
- } color={apiKey.enabled ? 'green' : 'default'} /> -
- {apiKey.title} -
-
- }, - { - head: t('settings.apiKeys.ipLimit'), - cell: apiKey => <> - {apiKey.limitIPs.length > 0 ? <> - {apiKey.limitIPs.slice(0, 5).join(', ')} - {apiKey.limitIPs.length > 5 && <>...} - : {t('settings.apiKeys.unrestricted')}} - - }, - { - head: t('common.actions'), - cell: apiKey => <> - - - - - } - ]; + const email = (userProfile && userProfile.userProfile) ? userProfile.userProfile.email : ''; return
- {isLoading && } - - {!isLoading && <> - - {t('settings.account')} - - - - - - {t('settings.newsletterSubscribe.title')} - - - - {t('settings.memberSince')} - {moment(signupDate*1000).calendar()} - - - - - {Config.sustainingMembership.enable && - {t('settings.sustainingMembership')} - - {userProfile && <> - - - - {((userProfile.userSubscription && userProfile.userSubscription.status !== SubscriptionStatus.INACTIVE) || isPaymentReturn) ? <> - {(userProfile.userSubscription.status === SubscriptionStatus.PENDING || (isPaymentReturn && !userProfile.userSubscription)) && <> - -
- {t('settings.subscriptionPending', { serviceName: Config.productName })} -
- } - {userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.ACTIVE && <> - -
- {t('settings.subscriptionActive', { serviceName: Config.productName })} -
- } - {userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.EXPIRING && <> - -
- {t('settings.subscriptionExpiring', { serviceName: Config.productName, expiresAt: moment(userProfile.userSubscription.cancelAt * 1000).calendar() })} -
- } - {userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.CANCELLED && <> - -
- {userProfile.userSubscription.isOnGracePeriod ? - <>{t('settings.subscriptionGracePeriod', { serviceName: Config.productName, expiresAt: moment(userProfile.userSubscription.gracePeriodEndsAt * 1000).calendar() })} : - <>{t('settings.subscriptionInactive', { serviceName: Config.productName })}} -
- } - : <> - {t('settings.sustainingMemberTeaser', { serviceName: Config.productName })} - } -
-
- - - {userProfile.userSubscription && userProfile.userSubscription.type==='stripe' && userProfile.userSubscription.status !== SubscriptionStatus.EXPIRING && - } - {userProfile.userSubscription && userProfile.userSubscription.type==='paddle' && - } - {!isPaymentReturn && ((!userProfile.userSubscription) || (userProfile.userSubscription.status === SubscriptionStatus.CANCELLED)) && - } - - -
- } -
} - - - - <Button - variant='contained' - size='small' - startIcon={<AddIcon />} - onClick={() => setShowCreateMFADevice(true)} - >{t('settings.mfa.add')}</Button> - </>}> - {t('settings.mfa.devices')} - - {t('settings.mfa.noDevices')}} - loading={isLoadingMFADevices} - rowIdentifier='mfaDeviceId' - /> - - - - - <Typography variant='caption' className={classes.quotaIndicator}> - {userProfile !== null && !isLoadingApiKeys && t('common.quotaIndicator', { cur: apiKeys.length, max: userProfile.userGroup.maxApiKeys})} - </Typography> - <ButtonGroup variant='contained' size='small'> - <Button - variant='contained' - size='small' - startIcon={<AddIcon />} - onClick={() => setShowCreateApiKey(true)} - disabled={userProfile === null || isLoadingApiKeys || apiKeys.length >= userProfile.userGroup.maxApiKeys} - >{t('settings.apiKeys.add')} - </Button> - <Button - variant='contained' - size='small' - startIcon={<DocsIcon />} - href={Config.apiDocsURL} - target='_blank' - >{t('settings.apiKeys.showDocs')} - </Button> - </ButtonGroup> - </div>}> - {t('settings.apiKeys.keys')} - -
{t('settings.apiKeys.noKeys')}} - loading={isLoadingApiKeys} - rowIdentifier='apiKeyId' - /> - - - - {t('settings.profile')} - - - setFirstName(target.value)} - pattern={RegexPatterns.name} - patternErrorText={t('common.checkInput')} - fullWidth - /> - - - setLastName(target.value)} - pattern={RegexPatterns.name} - patternErrorText={t('common.checkInput')} - fullWidth - /> - - - - - - {t('settings.defaultTimezone')} - - - - {t('jobs.timezone')} - - - - {t('settings.timezoneNote')} - - - - - - - - - - - } + setTabValue(val)} + indicatorColor="primary" + textColor="primary"> + + + + {Config.sustainingMembership.enable && } + + + {tabValue === 'profile' && } + {tabValue === 'security' && } + {tabValue === 'apiKeys' && } + {Config.sustainingMembership.enable && tabValue === 'billing' && } {showChangePassword && setShowChangePassword(false)} />} {showChangeEmail && setShowChangeEmail(false)} />} {showDeleteAccount && setShowDeleteAccount(false)} />} - {showSubscribeDialog && setShowSubscribeDialog(false)} />} - {showCreateMFADevice && setShowCreateMFADevice(false)} onRefreshMFADevices={() => refreshMFADevices()} username={email} />} - {deleteMFADevice!==null && setDeleteMFADevice(null)} onRefreshMFADevices={() => refreshMFADevices()} />} - {showApiKey!==null && setShowApiKey(null)} />} - {deleteApiKey!==null && setDeleteApiKey(null)} onRefreshAPIKeys={() => refreshApiKeys()} />} - {showCreateApiKey && setShowCreateApiKey(false)} onRefreshAPIKeys={() => refreshApiKeys()} />} - {editApiKey!==null && setEditApiKey(null)} onRefreshAPIKeys={() => refreshApiKeys()} />} - ; } diff --git a/frontend/src/components/settings/SettingsApiKeys.jsx b/frontend/src/components/settings/SettingsApiKeys.jsx new file mode 100644 index 00000000..e0266dba --- /dev/null +++ b/frontend/src/components/settings/SettingsApiKeys.jsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from 'react'; +import { Button, ButtonGroup, makeStyles, TableContainer, Typography, Paper } from '@material-ui/core'; +import { useTranslation } from 'react-i18next'; +import { getAPIKeys } from '../../utils/API'; +import useUserProfile from '../../hooks/useUserProfile'; +import Title from '../misc/Title'; +import ShowAPIKeyDialog from './ShowAPIKeyDialog'; +import DeleteAPIKeyDialog from './DeleteAPIKeyDialog'; +import CreateAPIKeyDialog from './CreateAPIKeyDialog'; +import EditAPIKeyDialog from './EditAPIKeyDialog'; +import ApiKeyIcon from '@material-ui/icons/VpnKey'; +import DeleteIcon from '@material-ui/icons/Delete'; +import ShowKeyIcon from '@material-ui/icons/Visibility'; +import EditIcon from '@material-ui/icons/Edit'; +import AddIcon from '@material-ui/icons/Add'; +import DocsIcon from '@material-ui/icons/HelpOutline'; +import IconAvatar from '../misc/IconAvatar'; +import Table from '../misc/Table'; +import { Config } from '../../utils/Config'; + +const useStyles = makeStyles(theme => ({ + quotaIndicator: { + marginRight: theme.spacing(2) + }, + actionButton: { + margin: theme.spacing(0.5) + } +})); + +export default function SettingsApiKeys() { + const classes = useStyles(); + const { t } = useTranslation(); + const userProfile = useUserProfile(); + + const [ isLoadingApiKeys, setIsLoadingApiKeys ] = useState(true); + const [ apiKeys, setApiKeys ] = useState([]); + const [ showCreateApiKey, setShowCreateApiKey ] = useState(false); + const [ showApiKey, setShowApiKey ] = useState(null); + const [ deleteApiKey, setDeleteApiKey ] = useState(null); + const [ editApiKey, setEditApiKey ] = useState(null); + + function refreshApiKeys() { + setIsLoadingApiKeys(true); + getAPIKeys() + .then(response => setApiKeys(response.apiKeys)) + .finally(() => setIsLoadingApiKeys(false)); + } + + useEffect(() => { + refreshApiKeys(); + }, []); + + const APIKEY_COLUMNS = [ + { + head: t('settings.apiKeys.title'), + cell: apiKey =>
+ } color={apiKey.enabled ? 'green' : 'default'} /> +
+ {apiKey.title} +
+
+ }, + { + head: t('settings.apiKeys.ipLimit'), + cell: apiKey => <> + {apiKey.limitIPs.length > 0 ? <> + {apiKey.limitIPs.slice(0, 5).join(', ')} + {apiKey.limitIPs.length > 5 && <>...} + : {t('settings.apiKeys.unrestricted')}} + + }, + { + head: t('common.actions'), + cell: apiKey => <> + + + + + } + ]; + + return <> + + + <Typography variant='caption' className={classes.quotaIndicator}> + {userProfile !== null && userProfile.userGroup && !isLoadingApiKeys && t('common.quotaIndicator', { cur: apiKeys.length, max: userProfile.userGroup.maxApiKeys})} + </Typography> + <ButtonGroup variant='contained' size='small'> + <Button + variant='contained' + size='small' + startIcon={<AddIcon />} + onClick={() => setShowCreateApiKey(true)} + disabled={!userProfile || !userProfile.userGroup || isLoadingApiKeys || apiKeys.length >= userProfile.userGroup.maxApiKeys} + >{t('settings.apiKeys.add')} + </Button> + <Button + variant='contained' + size='small' + startIcon={<DocsIcon />} + href={Config.apiDocsURL} + target='_blank' + rel='noopener' + >{t('settings.apiKeys.showDocs')} + </Button> + </ButtonGroup> + </div>}> + {t('settings.apiKeys.keys')} + +
{t('settings.apiKeys.noKeys')}} + loading={isLoadingApiKeys} + rowIdentifier='apiKeyId' + /> + + + {showApiKey!==null && setShowApiKey(null)} />} + {deleteApiKey!==null && setDeleteApiKey(null)} onRefreshAPIKeys={() => refreshApiKeys()} />} + {showCreateApiKey && setShowCreateApiKey(false)} onRefreshAPIKeys={() => refreshApiKeys()} />} + {editApiKey!==null && setEditApiKey(null)} onRefreshAPIKeys={() => refreshApiKeys()} />} + ; +} diff --git a/frontend/src/components/settings/SettingsBilling.jsx b/frontend/src/components/settings/SettingsBilling.jsx new file mode 100644 index 00000000..1aad536e --- /dev/null +++ b/frontend/src/components/settings/SettingsBilling.jsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Button, ButtonGroup, CircularProgress, Grid, makeStyles, Paper } from '@material-ui/core'; +import { useTranslation } from 'react-i18next'; +import { createBillingPortalSession, getSubscriptionLink, getUserProfile } from '../../utils/API'; +import useUserProfile from '../../hooks/useUserProfile'; +import Title from '../misc/Title'; +import { useSnackbar } from 'notistack'; +import { setUserProfile } from '../../redux/actions'; +import { useDispatch } from 'react-redux'; +import { SubscriptionStatus } from '../../utils/Constants'; +import ManageSubscriptionIcon from '@material-ui/icons/CreditCard'; +import SubscriptionActiveIcon from '@material-ui/icons/FavoriteBorder'; +import SubscriptionInactiveIcon from '@material-ui/icons/PauseCircleOutline'; +import LearnMoreIcon from '@material-ui/icons/Loyalty'; +import IconAvatar from '../misc/IconAvatar'; +import { Config } from '../../utils/Config'; +import SubscribeDialog from './SubscribeDialog'; +import moment from 'moment'; + +const useStyles = makeStyles(theme => ({ + paper: { + '&:not(:last-of-type)': { + marginBottom: theme.spacing(2) + } + }, + grid: { + '& .MuiGrid-item': { + padding: theme.spacing(2) + } + } +})); + +const REFRESH_INTERVAL = 2000; + +export default function SettingsBilling() { + const classes = useStyles(); + const { t } = useTranslation(); + const userProfile = useUserProfile(); + const dispatch = useDispatch(); + const { enqueueSnackbar } = useSnackbar(); + + const [ isLoadingManageSubscription, setIsLoadingManageSubscription ] = useState(false); + const [ showSubscribeDialog, setShowSubscribeDialog ] = useState(false); + + function manageSubscription() { + setIsLoadingManageSubscription(true); + + if (userProfile.userSubscription.type === 'stripe') { + createBillingPortalSession() + .then(response => window.location.href = response.url) + .catch(() => { + enqueueSnackbar(t('settings.manageSubscriptionFailed'), { variant: 'error' }); + setIsLoadingManageSubscription(false); + }); + } else if (userProfile.userSubscription.type === 'paddle') { + const wnd = window.open('', '_blank'); + + getSubscriptionLink('manage') + .then(response => { + if (wnd) { + wnd.location.href = response.url; + } else { + window.location.href = response.url; + } + }) + .catch(() => { + if (wnd) { + wnd.close(); + } + enqueueSnackbar(t('settings.manageSubscriptionFailed'), { variant: 'error' }); + }) + .finally(() => setIsLoadingManageSubscription(false)); + } + } + + const isCancelledSubscription = userProfile && userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.CANCELLED; + const isPaymentReturn = window && window.location && window.location.search === '?checkoutSuccess=true'; + + useEffect(() => { + function doRefreshProfile() { + getUserProfile().then(response => dispatch(setUserProfile(response))); + } + + if (isPaymentReturn && userProfile && (!userProfile.userSubscription || userProfile.userSubscription.status !== SubscriptionStatus.ACTIVE)) { + const handle = window.setInterval(doRefreshProfile, REFRESH_INTERVAL); + return () => window.clearInterval(handle); + } + }, [isPaymentReturn, userProfile, dispatch]); + + return <> + + {t('settings.sustainingMembership')} + + {userProfile && <> + + + + {((userProfile.userSubscription && userProfile.userSubscription.status !== SubscriptionStatus.INACTIVE) || isPaymentReturn) ? <> + {(userProfile.userSubscription.status === SubscriptionStatus.PENDING || (isPaymentReturn && !userProfile.userSubscription)) && <> + +
+ {t('settings.subscriptionPending', { serviceName: Config.productName })} +
+ } + {userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.ACTIVE && <> + +
+ {t('settings.subscriptionActive', { serviceName: Config.productName })} +
+ } + {userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.EXPIRING && <> + +
+ {t('settings.subscriptionExpiring', { serviceName: Config.productName, expiresAt: moment(userProfile.userSubscription.cancelAt * 1000).calendar() })} +
+ } + {userProfile.userSubscription && userProfile.userSubscription.status === SubscriptionStatus.CANCELLED && <> + +
+ {userProfile.userSubscription.isOnGracePeriod ? + <>{t('settings.subscriptionGracePeriod', { serviceName: Config.productName, expiresAt: moment(userProfile.userSubscription.gracePeriodEndsAt * 1000).calendar() })} : + <>{t('settings.subscriptionInactive', { serviceName: Config.productName })}} +
+ } + : <> + {t('settings.sustainingMemberTeaser', { serviceName: Config.productName })} + } +
+
+ + + {userProfile.userSubscription && userProfile.userSubscription.type==='stripe' && userProfile.userSubscription.status !== SubscriptionStatus.EXPIRING && + } + {userProfile.userSubscription && userProfile.userSubscription.type==='paddle' && + } + {!isPaymentReturn && ((!userProfile.userSubscription) || (userProfile.userSubscription.status === SubscriptionStatus.CANCELLED)) && + } + + +
+ } +
+ + {showSubscribeDialog && setShowSubscribeDialog(false)} />} + ; +} diff --git a/frontend/src/components/settings/SettingsProfile.jsx b/frontend/src/components/settings/SettingsProfile.jsx new file mode 100644 index 00000000..ff006567 --- /dev/null +++ b/frontend/src/components/settings/SettingsProfile.jsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import { Button, CircularProgress, Grid, InputLabel, LinearProgress, makeStyles, MenuItem, Paper, Select, Typography } from '@material-ui/core'; +import { grey } from '@material-ui/core/colors'; +import { useTranslation } from 'react-i18next'; +import { updateUserProfile } from '../../utils/API'; +import useTimezones from '../../hooks/useTimezones'; +import useUserProfile from '../../hooks/useUserProfile'; +import Title from '../misc/Title'; +import ValidatingTextField from '../misc/ValidatingTextField'; +import SaveIcon from '@material-ui/icons/Save'; +import { useSnackbar } from 'notistack'; +import { setUserProfile } from '../../redux/actions'; +import { useDispatch } from 'react-redux'; +import moment from 'moment'; +import { RegexPatterns } from '../../utils/Constants'; + +const useStyles = makeStyles(theme => ({ + grid: { + '& .MuiGrid-item': { + padding: theme.spacing(2) + } + }, + paper: { + '&:not(:last-of-type)': { + marginBottom: theme.spacing(2) + } + }, + timezoneNote: { + color: grey[600] + }, + signupDate: { + paddingTop: theme.spacing(0.5) + } +})); + +export default function SettingsProfile() { + const classes = useStyles(); + const { t } = useTranslation(); + const timezones = useTimezones(); + const userProfile = useUserProfile(); + const dispatch = useDispatch(); + const { enqueueSnackbar } = useSnackbar(); + + const [ isLoading, setIsLoading ] = useState(true); + const [ saving, setSaving ] = useState(false); + + const [ firstName, setFirstName ] = useState(''); + const [ lastName, setLastName ] = useState(''); + const [ timezone, setTimezone ] = useState(''); + const [ email, setEmail ] = useState(''); + const [ newsletterSubscribe, setNewsletterSubscribe ] = useState('undefined'); + const [ signupDate, setSignupDate ] = useState(0); + + useEffect(() => { + if (userProfile && userProfile.userProfile && Object.keys(userProfile.userProfile).length > 0) { + setFirstName(userProfile.userProfile.firstName); + setLastName(userProfile.userProfile.lastName); + setTimezone(userProfile.userProfile.timezone); + setEmail(userProfile.userProfile.email); + setNewsletterSubscribe(userProfile.userProfile.newsletterSubscribe); + setSignupDate(userProfile.userProfile.signupDate); + setIsLoading(false); + } + }, [userProfile]); + + function saveSettings() { + setSaving(true); + + const newProfile = { + ...userProfile, + userProfile: { + ...userProfile.userProfile, + firstName, + lastName, + timezone, + newsletterSubscribe + } + }; + + updateUserProfile(newProfile.userProfile) + .then(() => { + dispatch(setUserProfile(newProfile)); + enqueueSnackbar(t('settings.saved'), { variant: 'success' }); + }) + .catch(() => enqueueSnackbar(t('settings.saveFailed'), { variant: 'error' })) + .finally(() => setSaving(false)); + } + + if (isLoading) { + return ; + } + + return <> + + {t('settings.account')} + + + + + + {t('settings.newsletterSubscribe.title')} + + + + {t('settings.memberSince')} + {moment(signupDate*1000).calendar()} + + + + + + {t('settings.profile')} + + + setFirstName(target.value)} + pattern={RegexPatterns.name} + patternErrorText={t('common.checkInput')} + fullWidth + /> + + + setLastName(target.value)} + pattern={RegexPatterns.name} + patternErrorText={t('common.checkInput')} + fullWidth + /> + + + + + + {t('settings.defaultTimezone')} + + + + {t('jobs.timezone')} + + + + {t('settings.timezoneNote')} + + + + + + + + + + + ; +} diff --git a/frontend/src/components/settings/SettingsSecurity.jsx b/frontend/src/components/settings/SettingsSecurity.jsx new file mode 100644 index 00000000..19da2dbe --- /dev/null +++ b/frontend/src/components/settings/SettingsSecurity.jsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react'; +import { Button, makeStyles, TableContainer, Typography, Paper } from '@material-ui/core'; +import { useTranslation } from 'react-i18next'; +import { getMFADevices } from '../../utils/API'; +import useUserProfile from '../../hooks/useUserProfile'; +import Title from '../misc/Title'; +import CreateMFADeviceDialog from './CreateMFADeviceDialog'; +import DeleteMFADeviceDialog from './DeleteMFADeviceDialog'; +import TOTPDeviceIcon from '@material-ui/icons/PhoneIphone'; +import YubicoOTPDeviceIcon from '@material-ui/icons/VpnKey'; +import DeleteIcon from '@material-ui/icons/Delete'; +import AddIcon from '@material-ui/icons/Add'; +import IconAvatar from '../misc/IconAvatar'; +import Table from '../misc/Table'; + +const useStyles = makeStyles(theme => ({ + actionButton: { + margin: theme.spacing(0.5) + } +})); + +export default function SettingsSecurity() { + const classes = useStyles(); + const { t } = useTranslation(); + const userProfile = useUserProfile(); + + const [ isLoadingMFADevices, setIsLoadingMFADevices ] = useState(true); + const [ mfaDevices, setMFADevices ] = useState([]); + const [ showCreateMFADevice, setShowCreateMFADevice ] = useState(false); + const [ deleteMFADevice, setDeleteMFADevice ] = useState(null); + + function refreshMFADevices() { + setIsLoadingMFADevices(true); + getMFADevices() + .then(response => setMFADevices(response.mfaDevices)) + .finally(() => setIsLoadingMFADevices(false)); + } + + useEffect(() => { + refreshMFADevices(); + }, []); + + const MFA_COLUMNS = [ + { + head: t('settings.mfa.title'), + cell: mfaDevice =>
+ : mfaDevice.type===1 ? : <>} color={mfaDevice.enabled ? 'green' : 'default'} /> +
+
{mfaDevice.title}
+
+ {mfaDevice.type===0 && <>{t('settings.mfa.totpDevice.title')}} + {mfaDevice.type===1 && <>{t('settings.mfa.yubicoOtpDevice.title')}} +
+
+
+ }, + { + head: t('common.actions'), + cell: mfaDevice => <> + + + } + ]; + + return <> + + + <Button + variant='contained' + size='small' + startIcon={<AddIcon />} + onClick={() => setShowCreateMFADevice(true)} + disabled={!userProfile || !userProfile.userProfile} + >{t('settings.mfa.add')}</Button> + </>}> + {t('settings.mfa.devices')} + +
{t('settings.mfa.noDevices')}} + loading={isLoadingMFADevices} + rowIdentifier='mfaDeviceId' + /> + + + {showCreateMFADevice && setShowCreateMFADevice(false)} onRefreshMFADevices={() => refreshMFADevices()} username={userProfile && userProfile.userProfile ? userProfile.userProfile.email : ''} />} + {deleteMFADevice!==null && setDeleteMFADevice(null)} onRefreshMFADevices={() => refreshMFADevices()} />} + ; +} diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index 17e568cc..0bfb107b 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -505,6 +505,12 @@ } }, "settings": { + "tabs": { + "profile": "Profil", + "security": "Sicherheit", + "apiKeys": "API-Schlüssel", + "billing": "Abrechnung" + }, "profile": "Profil", "firstName": "Vorname", "lastName": "Nachname", diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index 28fccd13..f2f67628 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -505,6 +505,12 @@ } }, "settings": { + "tabs": { + "profile": "Profile", + "security": "Security", + "apiKeys": "API Keys", + "billing": "Billing" + }, "profile": "Profile", "firstName": "First name", "lastName": "Last name", From e7f612698a3f1d3094a6bc33b8159c664635b937 Mon Sep 17 00:00:00 2001 From: santichausis Date: Thu, 9 Jul 2026 15:14:25 -0300 Subject: [PATCH 2/2] frontend: Group Profile tab content in fieldsets within a single box Addresses review feedback on #444: the Profile tab previously used three separate Paper boxes, and the tab's underline only visually connected to the first one, making it unclear that the other two belonged to the same tab. Now the whole tab content lives in a single Paper, with each section (Account, Profile, Timezone) grouped by a fieldset/legend, matching the pattern already used in JobEditor. --- .../components/settings/SettingsProfile.jsx | 160 +++++++++--------- 1 file changed, 84 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/settings/SettingsProfile.jsx b/frontend/src/components/settings/SettingsProfile.jsx index ff006567..23588ac9 100644 --- a/frontend/src/components/settings/SettingsProfile.jsx +++ b/frontend/src/components/settings/SettingsProfile.jsx @@ -1,11 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { Button, CircularProgress, Grid, InputLabel, LinearProgress, makeStyles, MenuItem, Paper, Select, Typography } from '@material-ui/core'; +import { Button, CircularProgress, FormLabel, Grid, InputLabel, LinearProgress, makeStyles, MenuItem, Paper, Select, Typography } from '@material-ui/core'; import { grey } from '@material-ui/core/colors'; import { useTranslation } from 'react-i18next'; import { updateUserProfile } from '../../utils/API'; import useTimezones from '../../hooks/useTimezones'; import useUserProfile from '../../hooks/useUserProfile'; -import Title from '../misc/Title'; import ValidatingTextField from '../misc/ValidatingTextField'; import SaveIcon from '@material-ui/icons/Save'; import { useSnackbar } from 'notistack'; @@ -21,8 +20,15 @@ const useStyles = makeStyles(theme => ({ } }, paper: { - '&:not(:last-of-type)': { - marginBottom: theme.spacing(2) + padding: theme.spacing(2) + }, + fieldSet: { + padding: theme.spacing(2), + margin: theme.spacing(2, 0), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.spacing(0.5), + '& legend': { + padding: theme.spacing(0, 1) } }, timezoneNote: { @@ -92,83 +98,85 @@ export default function SettingsProfile() { return <> - {t('settings.account')} - - - - - - {t('settings.newsletterSubscribe.title')} - - - - {t('settings.memberSince')} - {moment(signupDate*1000).calendar()} +
+ {t('settings.account')} + + + + + + {t('settings.newsletterSubscribe.title')} + + + + {t('settings.memberSince')} + {moment(signupDate*1000).calendar()} + - - +
- - {t('settings.profile')} - - - setFirstName(target.value)} - pattern={RegexPatterns.name} - patternErrorText={t('common.checkInput')} - fullWidth - /> +
+ {t('settings.profile')} + + + setFirstName(target.value)} + pattern={RegexPatterns.name} + patternErrorText={t('common.checkInput')} + fullWidth + /> + + + setLastName(target.value)} + pattern={RegexPatterns.name} + patternErrorText={t('common.checkInput')} + fullWidth + /> + - - setLastName(target.value)} - pattern={RegexPatterns.name} - patternErrorText={t('common.checkInput')} - fullWidth - /> - - - +
- - {t('settings.defaultTimezone')} - - - - {t('jobs.timezone')} - - - - {t('settings.timezoneNote')} - +
+ {t('settings.defaultTimezone')} + + + + {t('jobs.timezone')} + + + + {t('settings.timezoneNote')} + + - +