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..23588ac9 --- /dev/null +++ b/frontend/src/components/settings/SettingsProfile.jsx @@ -0,0 +1,195 @@ +import React, { useEffect, useState } from 'react'; +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 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: { + 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: { + 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",