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 => <>
- }
- className={classes.actionButton}
- onClick={() => setDeleteMFADevice(mfaDevice)}
- >
- {t('common.delete')}
-
- >
- }
- ];
-
- 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 => <>
- }
- className={classes.actionButton}
- onClick={() => setShowApiKey(apiKey)}
- >
- {t('settings.apiKeys.showKey')}
-
- }
- className={classes.actionButton}
- onClick={() => setEditApiKey(apiKey)}
- >
- {t('common.edit')}
-
- }
- className={classes.actionButton}
- onClick={() => setDeleteApiKey(apiKey)}
- >
- {t('common.delete')}
-
- >
- }
- ];
+ 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 &&
- : }
- onClick={manageSubscription}
- disabled={isLoadingManageSubscription}
- float='right'
- >
- {t('settings.manageSubscription')}
- }
- {userProfile.userSubscription && userProfile.userSubscription.type==='paddle' &&
- : }
- onClick={manageSubscription}
- disabled={isLoadingManageSubscription}
- float='right'
- >
- {t('settings.manageSubscription')}
- }
- {!isPaymentReturn && ((!userProfile.userSubscription) || (userProfile.userSubscription.status === SubscriptionStatus.CANCELLED)) &&
- }
- onClick={() => setShowSubscribeDialog(true)}
- float='right'
- >
- {t(isCancelledSubscription ? 'settings.becomeASustainingMember' : 'settings.learnMore')}
- }
-
-
-
- >}
- }
-
-
-
- }
- onClick={() => setShowCreateMFADevice(true)}
- >{t('settings.mfa.add')}
- >}>
- {t('settings.mfa.devices')}
-
- {t('settings.mfa.noDevices')}}
- loading={isLoadingMFADevices}
- rowIdentifier='mfaDeviceId'
- />
-
-
-
-
-
- {userProfile !== null && !isLoadingApiKeys && t('common.quotaIndicator', { cur: apiKeys.length, max: userProfile.userGroup.maxApiKeys})}
-
-
- }
- onClick={() => setShowCreateApiKey(true)}
- disabled={userProfile === null || isLoadingApiKeys || apiKeys.length >= userProfile.userGroup.maxApiKeys}
- >{t('settings.apiKeys.add')}
-
- }
- href={Config.apiDocsURL}
- target='_blank'
- >{t('settings.apiKeys.showDocs')}
-
-
- }>
- {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')}
-
-
-
-
-
-
-
- : }
- className={classes.saveButton}
- onClick={() => saveSettings()}
- disabled={saving}>
- {t('common.save')}
-
-
-
- >}
+ 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 => <>
+ }
+ className={classes.actionButton}
+ onClick={() => setShowApiKey(apiKey)}
+ >
+ {t('settings.apiKeys.showKey')}
+
+ }
+ className={classes.actionButton}
+ onClick={() => setEditApiKey(apiKey)}
+ >
+ {t('common.edit')}
+
+ }
+ className={classes.actionButton}
+ onClick={() => setDeleteApiKey(apiKey)}
+ >
+ {t('common.delete')}
+
+ >
+ }
+ ];
+
+ return <>
+
+
+
+ {userProfile !== null && userProfile.userGroup && !isLoadingApiKeys && t('common.quotaIndicator', { cur: apiKeys.length, max: userProfile.userGroup.maxApiKeys})}
+
+
+ }
+ onClick={() => setShowCreateApiKey(true)}
+ disabled={!userProfile || !userProfile.userGroup || isLoadingApiKeys || apiKeys.length >= userProfile.userGroup.maxApiKeys}
+ >{t('settings.apiKeys.add')}
+
+ }
+ href={Config.apiDocsURL}
+ target='_blank'
+ rel='noopener'
+ >{t('settings.apiKeys.showDocs')}
+
+
+ }>
+ {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 &&
+ : }
+ onClick={manageSubscription}
+ disabled={isLoadingManageSubscription}
+ float='right'
+ >
+ {t('settings.manageSubscription')}
+ }
+ {userProfile.userSubscription && userProfile.userSubscription.type==='paddle' &&
+ : }
+ onClick={manageSubscription}
+ disabled={isLoadingManageSubscription}
+ float='right'
+ >
+ {t('settings.manageSubscription')}
+ }
+ {!isPaymentReturn && ((!userProfile.userSubscription) || (userProfile.userSubscription.status === SubscriptionStatus.CANCELLED)) &&
+ }
+ onClick={() => setShowSubscribeDialog(true)}
+ float='right'
+ >
+ {t(isCancelledSubscription ? 'settings.becomeASustainingMember' : 'settings.learnMore')}
+ }
+
+
+
+ >}
+
+
+ {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 <>
+
+
+
+
+
+
+
+
+
+
+ : }
+ onClick={() => saveSettings()}
+ disabled={saving}>
+ {t('common.save')}
+
+
+
+ >;
+}
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 => <>
+ }
+ className={classes.actionButton}
+ onClick={() => setDeleteMFADevice(mfaDevice)}
+ >
+ {t('common.delete')}
+
+ >
+ }
+ ];
+
+ return <>
+
+
+ }
+ onClick={() => setShowCreateMFADevice(true)}
+ disabled={!userProfile || !userProfile.userProfile}
+ >{t('settings.mfa.add')}
+ >}>
+ {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",