feat: Add two-factor authentication via TOTP

Adds TOTP setup with QR code, login challenge, recovery codes and
trusted devices that let a browser skip the second factor for 30
days. Admins can reset another user's second factor by confirming
with their own password.
This commit is contained in:
Daniel Hiller
2026-08-07 20:11:55 +02:00
parent 36aa732fec
commit 2e4904f77d
74 changed files with 4173 additions and 4 deletions
+10
View File
@@ -58,6 +58,7 @@
"patch-package": "^8.0.1",
"photoswipe": "^5.4.4",
"prop-types": "^15.8.1",
"qrcode.react": "^4.2.0",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-datepicker": "^9.1.0",
@@ -14345,6 +14346,15 @@
],
"license": "MIT"
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+1
View File
@@ -138,6 +138,7 @@
"patch-package": "^8.0.1",
"photoswipe": "^5.4.4",
"prop-types": "^15.8.1",
"qrcode.react": "^4.2.0",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-datepicker": "^9.1.0",
+40
View File
@@ -98,6 +98,44 @@ updateTermsLanguage.failure = (error) => ({
},
});
const verifyTotp = (data) => ({
type: ActionTypes.TOTP_VERIFY,
payload: {
data,
},
});
verifyTotp.success = (accessToken) => ({
type: ActionTypes.TOTP_VERIFY__SUCCESS,
payload: {
accessToken,
},
});
verifyTotp.failure = (error) => ({
type: ActionTypes.TOTP_VERIFY__FAILURE,
payload: {
error,
},
});
const cancelTotpChallenge = () => ({
type: ActionTypes.TOTP_CHALLENGE_CANCEL,
payload: {},
});
cancelTotpChallenge.success = () => ({
type: ActionTypes.TOTP_CHALLENGE_CANCEL__SUCCESS,
payload: {},
});
cancelTotpChallenge.failure = (error) => ({
type: ActionTypes.TOTP_CHALLENGE_CANCEL__FAILURE,
payload: {
error,
},
});
export default {
initializeLogin,
authenticate,
@@ -105,4 +143,6 @@ export default {
acceptTerms,
cancelTerms,
updateTermsLanguage,
verifyTotp,
cancelTotpChallenge,
};
+161
View File
@@ -294,6 +294,159 @@ const clearUserApiKeyValue = (id) => ({
},
});
const setupUserTotp = (id) => ({
type: ActionTypes.USER_TOTP_SETUP,
payload: {
id,
},
});
setupUserTotp.success = (id, setup) => ({
type: ActionTypes.USER_TOTP_SETUP__SUCCESS,
payload: {
id,
setup,
},
});
setupUserTotp.failure = (id, error) => ({
type: ActionTypes.USER_TOTP_SETUP__FAILURE,
payload: {
id,
error,
},
});
const clearUserTotpSetupValue = (id) => ({
type: ActionTypes.USER_TOTP_SETUP_VALUE_CLEAR,
payload: {
id,
},
});
const enableUserTotp = (id) => ({
type: ActionTypes.USER_TOTP_ENABLE,
payload: {
id,
},
});
enableUserTotp.success = (user, recoveryCodes) => ({
type: ActionTypes.USER_TOTP_ENABLE__SUCCESS,
payload: {
user,
recoveryCodes,
},
});
enableUserTotp.failure = (id, error) => ({
type: ActionTypes.USER_TOTP_ENABLE__FAILURE,
payload: {
id,
error,
},
});
const disableUserTotp = (id) => ({
type: ActionTypes.USER_TOTP_DISABLE,
payload: {
id,
},
});
disableUserTotp.success = (user) => ({
type: ActionTypes.USER_TOTP_DISABLE__SUCCESS,
payload: {
user,
},
});
disableUserTotp.failure = (id, error) => ({
type: ActionTypes.USER_TOTP_DISABLE__FAILURE,
payload: {
id,
error,
},
});
const regenerateUserTotpRecoveryCodes = (id) => ({
type: ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE,
payload: {
id,
},
});
regenerateUserTotpRecoveryCodes.success = (id, recoveryCodes) => ({
type: ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE__SUCCESS,
payload: {
id,
recoveryCodes,
},
});
regenerateUserTotpRecoveryCodes.failure = (id, error) => ({
type: ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE__FAILURE,
payload: {
id,
error,
},
});
const clearUserTotpRecoveryCodes = (id) => ({
type: ActionTypes.USER_TOTP_RECOVERY_CODES_CLEAR,
payload: {
id,
},
});
const fetchUserTrustedDevices = (id) => ({
type: ActionTypes.USER_TRUSTED_DEVICES_FETCH,
payload: {
id,
},
});
fetchUserTrustedDevices.success = (id, devices) => ({
type: ActionTypes.USER_TRUSTED_DEVICES_FETCH__SUCCESS,
payload: {
id,
devices,
},
});
fetchUserTrustedDevices.failure = (id, error) => ({
type: ActionTypes.USER_TRUSTED_DEVICES_FETCH__FAILURE,
payload: {
id,
error,
},
});
const deleteUserTrustedDevice = (id, deviceId) => ({
type: ActionTypes.USER_TRUSTED_DEVICE_DELETE,
payload: {
id,
deviceId,
},
});
deleteUserTrustedDevice.success = (id, device) => ({
type: ActionTypes.USER_TRUSTED_DEVICE_DELETE__SUCCESS,
payload: {
id,
device,
},
});
deleteUserTrustedDevice.failure = (id, deviceId, error) => ({
type: ActionTypes.USER_TRUSTED_DEVICE_DELETE__FAILURE,
payload: {
id,
deviceId,
error,
},
});
const deleteUser = (id) => ({
type: ActionTypes.USER_DELETE,
payload: {
@@ -422,6 +575,14 @@ export default {
createUserApiKey,
deleteUserApiKey,
clearUserApiKeyValue,
setupUserTotp,
clearUserTotpSetupValue,
enableUserTotp,
disableUserTotp,
regenerateUserTotpRecoveryCodes,
clearUserTotpRecoveryCodes,
fetchUserTrustedDevices,
deleteUserTrustedDevice,
deleteUser,
handleUserDelete,
addUserToCard,
+4
View File
@@ -10,6 +10,9 @@ import http from './http';
const createAccessToken = (data, headers) =>
http.post('/access-tokens?withHttpOnlyToken=true', data, headers);
const verifyTotp = (data, headers) =>
http.post('/access-tokens/verify-totp?withHttpOnlyToken=true', data, headers);
// TODO: rename?
const acceptTerms = (data, headers) => http.post('/access-tokens/accept-terms', data, headers);
@@ -20,6 +23,7 @@ const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', u
export default {
createAccessToken,
verifyTotp,
acceptTerms,
revokePendingToken,
deleteCurrentAccessToken,
+22
View File
@@ -36,6 +36,22 @@ const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`,
const createUserApiKey = (userId, headers) =>
socket.post(`/users/${userId}/api-key`, undefined, headers);
const setupUserTotp = (id, data, headers) => socket.post(`/users/${id}/totp/setup`, data, headers);
const enableUserTotp = (id, data, headers) =>
socket.post(`/users/${id}/totp/enable`, data, headers);
const disableUserTotp = (id, data, headers) => socket.delete(`/users/${id}/totp`, data, headers);
const regenerateUserTotpRecoveryCodes = (id, data, headers) =>
socket.post(`/users/${id}/totp/recovery-codes`, data, headers);
const getUserTrustedDevices = (id, headers) =>
socket.get(`/users/${id}/trusted-devices`, undefined, headers);
const deleteUserTrustedDevice = (id, deviceId, headers) =>
socket.delete(`/users/${id}/trusted-devices/${deviceId}`, undefined, headers);
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
export default {
@@ -49,5 +65,11 @@ export default {
updateUserUsername,
updateUserAvatar,
createUserApiKey,
setupUserTotp,
enableUserTotp,
disableUserTotp,
regenerateUserTotpRecoveryCodes,
getUserTrustedDevices,
deleteUserTrustedDevice,
deleteUser,
};
@@ -15,6 +15,7 @@ import entryActions from '../../../../entry-actions';
import { useSteps } from '../../../../hooks';
import SelectRoleStep from './SelectRoleStep';
import ApiKeyStep from './ApiKeyStep';
import ResetTotpStep from './ResetTotpStep';
import ConfirmationStep from '../../ConfirmationStep';
import EditUserInformationStep from '../../../users/EditUserInformationStep';
import EditUserAvatarStep from '../../../users/EditUserAvatarStep';
@@ -32,6 +33,7 @@ const StepTypes = {
EDIT_PASSWORD: 'EDIT_PASSWORD',
EDIT_ROLE: 'EDIT_ROLE',
API_KEY: 'API_KEY',
RESET_TOTP: 'RESET_TOTP',
ACTIVATE: 'ACTIVATE',
DEACTIVATE: 'DEACTIVATE',
DELETE: 'DELETE',
@@ -112,6 +114,10 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
openStep(StepTypes.API_KEY);
}, [openStep]);
const handleResetTotpClick = useCallback(() => {
openStep(StepTypes.RESET_TOTP);
}, [openStep]);
const handleActivateClick = useCallback(() => {
openStep(StepTypes.ACTIVATE);
}, [openStep]);
@@ -150,6 +156,8 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
);
case StepTypes.API_KEY:
return <ApiKeyStep userId={userId} onBack={handleBack} onClose={onClose} />;
case StepTypes.RESET_TOTP:
return <ResetTotpStep userId={userId} onBack={handleBack} onClose={onClose} />;
case StepTypes.ACTIVATE:
return (
<ConfirmationStep
@@ -246,6 +254,14 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
context: 'title',
})}
</Menu.Item>
{user.isTotpEnabled && !isCurrentUser && (
<Menu.Item className={styles.menuItem} onClick={handleResetTotpClick}>
<Icon name="shield alternate" className={styles.menuItemIcon} />
{t('common.reset2fa', {
context: 'title',
})}
</Menu.Item>
)}
{!isCurrentUser && (
<>
<Menu.Item
@@ -0,0 +1,131 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message } from 'semantic-ui-react';
import { Input, Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useForm, useNestedRef } from '../../../../hooks';
import styles from './ResetTotpStep.module.scss';
const createMessage = (error) => {
if (!error) {
return error;
}
switch (error.message) {
case 'Invalid current password':
return {
type: 'error',
content: 'common.invalidCurrentPassword',
};
default:
return {
type: 'warning',
content: 'common.unknownError',
};
}
};
const ResetTotpStep = React.memo(({ userId, onBack, onClose }) => {
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const user = useSelector((state) => selectUserById(state, userId));
const dispatch = useDispatch();
const [t] = useTranslation();
const { isDisabling, error } = user.totpState || {};
const wasDisablingRef = useRef(false);
const [data, handleFieldChange] = useForm({
currentPassword: '',
});
const [currentPasswordFieldRef, handleCurrentPasswordFieldRef] = useNestedRef('inputRef');
const message = useMemo(() => createMessage(error), [error]);
// The admin confirms with their own password, so the only signal that the
// reset went through is the request finishing without an error.
useEffect(() => {
if (wasDisablingRef.current && !isDisabling && !error) {
onClose();
}
wasDisablingRef.current = isDisabling;
}, [isDisabling, error, onClose]);
const handleSubmit = useCallback(() => {
if (!data.currentPassword) {
currentPasswordFieldRef.current.focus();
return;
}
dispatch(
entryActions.disableUserTotp(userId, {
currentPassword: data.currentPassword,
}),
);
}, [dispatch, userId, data.currentPassword, currentPasswordFieldRef]);
return (
<>
<Popup.Header onBack={onBack}>
{t('common.reset2fa', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<p className={styles.warning}>{t('common.reset2faWarning')}</p>
{message && (
<Message
{...{
[message.type]: true,
}}
visible
content={t(message.content)}
/>
)}
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.currentPassword')}</div>
<Input.Password
fluid
ref={handleCurrentPasswordFieldRef}
name="currentPassword"
value={data.currentPassword}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
<Button
negative
content={t('action.reset2fa')}
icon="shield alternate"
loading={isDisabling}
disabled={isDisabling}
/>
</Form>
</Popup.Content>
</>
);
});
ResetTotpStep.propTypes = {
userId: PropTypes.string.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
ResetTotpStep.defaultProps = {
onBack: undefined,
};
export default ResetTotpStep;
@@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
.warning {
color: #444444;
font-size: 12px;
margin-bottom: 12px;
}
}
@@ -18,6 +18,7 @@ import { useForm, useNestedRef } from '../../../hooks';
import { isUsername } from '../../../utils/validator';
import AccessTokenSteps from '../../../constants/AccessTokenSteps';
import TermsModal from './TermsModal';
import TotpChallengeModal from './TotpChallengeModal';
import logo from '../../../assets/images/logo.png';
@@ -268,6 +269,7 @@ const Content = React.memo(() => {
</Grid.Column>
</Grid>
{step === AccessTokenSteps.ACCEPT_TERMS && <TermsModal />}
{step === AccessTokenSteps.VERIFY_TOTP && <TotpChallengeModal />}
</div>
);
});
@@ -0,0 +1,124 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Form, Message, Modal } from 'semantic-ui-react';
import { Input } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useNestedRef } from '../../../hooks';
import styles from './TotpChallengeModal.module.scss';
const sanitizeCode = (value) => value.replace(/\s+/g, '').toLowerCase();
const createMessage = (error) => {
if (!error) return null;
if (error.message === 'Invalid TOTP code') {
return { type: 'error', content: 'common.invalidTotpCode' };
}
if (error.message === 'Invalid pending token') {
return { type: 'error', content: 'common.totpSessionExpired' };
}
return { type: 'warning', content: 'common.unknownError' };
};
const TotpChallengeModal = React.memo(() => {
const {
totpForm: { isSubmitting, isCancelling, error },
} = useSelector(selectors.selectAuthenticateForm);
const dispatch = useDispatch();
const [t] = useTranslation();
const [code, setCode] = useState('');
const [trustDevice, setTrustDevice] = useState(false);
const [codeFieldRef, handleCodeFieldRef] = useNestedRef('inputRef');
const message = useMemo(() => createMessage(error), [error]);
const handleSubmit = useCallback(() => {
const trimmed = sanitizeCode(code);
if (!trimmed) {
if (codeFieldRef.current) codeFieldRef.current.focus();
return;
}
dispatch(entryActions.verifyTotp({ code: trimmed, trustDevice }));
}, [dispatch, code, trustDevice, codeFieldRef]);
const handleCancelClick = useCallback(() => {
dispatch(entryActions.cancelTotpChallenge());
}, [dispatch]);
const handleCodeChange = useCallback((_, { value }) => {
setCode(value);
}, []);
const handleTrustDeviceChange = useCallback((_, { checked }) => {
setTrustDevice(checked);
}, []);
return (
<Modal open centered size="tiny" closeOnDimmerClick={false} closeOnEscape={false}>
<Modal.Header>{t('common.twoFactorRequired_title')}</Modal.Header>
<Modal.Content>
<p className={styles.intro}>{t('common.enterTotpOrRecoveryCode')}</p>
{message && (
<Message
{...{
[message.type]: true,
}}
content={t(message.content)}
/>
)}
<Form onSubmit={handleSubmit}>
<Form.Field>
<Input
fluid
autoFocus
ref={handleCodeFieldRef}
value={code}
maxLength={16}
placeholder="000000 / xxxxx-xxxxx"
autoComplete="one-time-code"
readOnly={isSubmitting}
className={styles.codeInput}
onChange={handleCodeChange}
/>
</Form.Field>
<Form.Field>
<Checkbox
label={t('common.trustThisBrowser')}
checked={trustDevice}
disabled={isSubmitting || isCancelling}
onChange={handleTrustDeviceChange}
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button
content={t('action.cancelAndClose')}
floated="left"
loading={isCancelling}
disabled={isSubmitting || isCancelling}
onClick={handleCancelClick}
/>
<Button
positive
content={t('action.verify')}
loading={isSubmitting}
disabled={isSubmitting || isCancelling || !code}
onClick={handleSubmit}
/>
</Modal.Actions>
</Modal>
);
});
export default TotpChallengeModal;
@@ -0,0 +1,18 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.intro {
color: var(--text-secondary);
margin-bottom: 16px;
}
.codeInput {
font-family: 'JetBrains Mono', 'Menlo', 'Courier New', monospace;
font-size: 18px;
letter-spacing: 2px;
text-align: center;
}
}
@@ -0,0 +1,74 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import styles from './RecoveryCodesView.module.scss';
const RecoveryCodesView = React.memo(({ codes, className }) => {
const [t] = useTranslation();
const handleCopyClick = useCallback(() => {
const text = codes.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => {});
}
}, [codes]);
const handleDownloadClick = useCallback(() => {
const text = codes.join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'planka-recovery-codes.txt';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [codes]);
return (
<div className={[styles.wrapper, className].filter(Boolean).join(' ')}>
<ul className={styles.list}>
{codes.map((code) => (
<li key={code}>
<code>{code}</code>
</li>
))}
</ul>
<div className={styles.actions}>
<Button
basic
size="small"
icon="copy"
content={t('action.copyAll')}
onClick={handleCopyClick}
/>
<Button
basic
size="small"
icon="download"
content={t('action.download')}
onClick={handleDownloadClick}
/>
</div>
</div>
);
});
RecoveryCodesView.propTypes = {
codes: PropTypes.arrayOf(PropTypes.string).isRequired,
className: PropTypes.string,
};
RecoveryCodesView.defaultProps = {
className: undefined,
};
export default RecoveryCodesView;
@@ -0,0 +1,33 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
background: var(--surface-raised);
border: 1px solid var(--grey-e0e0e0);
border-radius: 6px;
padding: 12px;
}
.list {
column-count: 2;
column-gap: 16px;
font-family: 'JetBrains Mono', 'Menlo', 'Courier New', monospace;
font-size: 14px;
list-style: none;
margin: 0 0 12px;
padding: 0;
li {
break-inside: avoid;
padding: 2px 0;
}
}
.actions {
display: flex;
gap: 8px;
}
}
@@ -0,0 +1,124 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Header, Icon, Message, Tab } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import TotpSetupWizard from './TotpSetupWizard';
import TotpDisableModal from './TotpDisableModal';
import TotpRecoveryCodesModal from './TotpRecoveryCodesModal';
import RecoveryCodesView from './RecoveryCodesView';
import TrustedDevicesSection from './TrustedDevicesSection';
import styles from './SecurityPane.module.scss';
const SecurityPane = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false);
const handleEnableClick = useCallback(() => {
setIsWizardOpen(true);
}, []);
const handleWizardClose = useCallback(() => {
setIsWizardOpen(false);
dispatch(entryActions.clearCurrentUserTotpSetupValue());
}, [dispatch]);
const handleDisableClick = useCallback(() => {
setIsDisableModalOpen(true);
}, []);
const handleDisableClose = useCallback(() => {
setIsDisableModalOpen(false);
}, []);
const handleRegenerateClick = useCallback(() => {
setIsRegenerateModalOpen(true);
}, []);
const handleRegenerateClose = useCallback(() => {
setIsRegenerateModalOpen(false);
dispatch(entryActions.clearCurrentUserTotpRecoveryCodes());
}, [dispatch]);
const totpState = user.totpState || {};
const showRecoveryCodes = totpState.recoveryCodes && totpState.recoveryCodes.length > 0;
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Header as="h3">{t('common.twoFactorAuthentication')}</Header>
{user.isTotpEnabled ? (
<>
<Message positive>
<Icon name="shield" />
<span>{t('common.twoFactor_enabled')}</span>
{user.totpEnabledAt && (
<span className={styles.enabledMeta}>
{' — '}
{t('common.enabledOn', {
date: new Date(user.totpEnabledAt).toLocaleString(),
})}
</span>
)}
</Message>
{typeof user.totpRecoveryCodesRemaining === 'number' &&
user.totpRecoveryCodesRemaining <= 3 && (
<Message warning>
<Icon name="warning sign" />
{user.totpRecoveryCodesRemaining === 0
? t('common.recoveryCodesExhausted')
: t('common.recoveryCodesLow', { count: user.totpRecoveryCodesRemaining })}
</Message>
)}
<div className={styles.actionRow}>
<Button
basic
icon="refresh"
content={t('action.regenerateRecoveryCodes')}
onClick={handleRegenerateClick}
/>
<Button
negative
icon="shield alternate"
content={t('action.disable2fa')}
onClick={handleDisableClick}
/>
</div>
{showRecoveryCodes && (
<RecoveryCodesView codes={totpState.recoveryCodes} className={styles.recoverySection} />
)}
<TrustedDevicesSection />
</>
) : (
<>
<p className={styles.intro}>{t('common.twoFactor_intro')}</p>
<Button
primary
icon="shield"
content={t('action.enable2fa')}
onClick={handleEnableClick}
/>
</>
)}
{isWizardOpen && <TotpSetupWizard onClose={handleWizardClose} />}
{isDisableModalOpen && <TotpDisableModal onClose={handleDisableClose} />}
{isRegenerateModalOpen && <TotpRecoveryCodesModal onClose={handleRegenerateClose} />}
</Tab.Pane>
);
});
export default SecurityPane;
@@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
.intro {
color: var(--text-secondary);
margin-bottom: 16px;
}
.enabledMeta {
color: var(--text-secondary);
font-weight: normal;
}
.actionRow {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.recoverySection {
margin-top: 20px;
}
}
@@ -0,0 +1,99 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message, Modal } from 'semantic-ui-react';
import { Input } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
const TotpDisableModal = React.memo(({ onClose }) => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const totpState = user.totpState || {};
const { isDisabling, error } = totpState;
const wasDisablingRef = useRef(false);
useEffect(() => {
if (wasDisablingRef.current && !isDisabling && !error && !user.isTotpEnabled) {
onClose();
}
wasDisablingRef.current = isDisabling;
}, [isDisabling, error, user.isTotpEnabled, onClose]);
const handleSubmit = useCallback(() => {
if (!password || !code) return;
dispatch(
entryActions.disableCurrentUserTotp({
currentPassword: password,
code: code.replace(/\s+/g, ''),
}),
);
}, [dispatch, password, code]);
return (
<Modal open centered size="tiny" closeOnDimmerClick={false} onClose={onClose}>
<Modal.Header>{t('common.disable2fa_title')}</Modal.Header>
<Modal.Content>
<p>{t('common.disable2faWarning')}</p>
{error && error.message === 'Invalid current password' && (
<Message error content={t('common.invalidCurrentPassword')} />
)}
{error && error.message === 'Invalid TOTP code' && (
<Message error content={t('common.invalidTotpCode')} />
)}
<Form onSubmit={handleSubmit}>
<Form.Field>
<label htmlFor="totp-disable-password">{t('common.currentPassword')}</label>
<Input.Password
fluid
id="totp-disable-password"
value={password}
maxLength={256}
onChange={(_, { value }) => setPassword(value)}
/>
</Form.Field>
<Form.Field>
<label htmlFor="totp-disable-code">{t('common.totpOrRecoveryCode')}</label>
<Input
fluid
id="totp-disable-code"
value={code}
maxLength={16}
placeholder="000000 / xxxxx-xxxxx"
autoComplete="one-time-code"
onChange={(_, { value }) => setCode(value)}
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button content={t('action.cancel')} floated="left" onClick={onClose} />
<Button
negative
content={t('action.disable2fa')}
loading={isDisabling}
disabled={isDisabling || !password || !code}
onClick={handleSubmit}
/>
</Modal.Actions>
</Modal>
);
});
TotpDisableModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default TotpDisableModal;
@@ -0,0 +1,114 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message, Modal } from 'semantic-ui-react';
import { Input } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import RecoveryCodesView from './RecoveryCodesView';
const TotpRecoveryCodesModal = React.memo(({ onClose }) => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const totpState = user.totpState || {};
const { isRegeneratingRecoveryCodes, error, recoveryCodes } = totpState;
const handleSubmit = useCallback(() => {
if (!password || !code) return;
dispatch(
entryActions.regenerateCurrentUserTotpRecoveryCodes({
currentPassword: password,
code: code.replace(/\s+/g, ''),
}),
);
}, [dispatch, password, code]);
const hasCodes = recoveryCodes && recoveryCodes.length > 0;
return (
<Modal open centered size="small" closeOnDimmerClick={false} onClose={onClose}>
<Modal.Header>{t('common.regenerateRecoveryCodes_title')}</Modal.Header>
<Modal.Content>
{!hasCodes && (
<>
<p>{t('common.regenerateRecoveryCodesIntro')}</p>
{error && error.message === 'Invalid current password' && (
<Message error content={t('common.invalidCurrentPassword')} />
)}
{error && error.message === 'Invalid TOTP code' && (
<Message error content={t('common.invalidTotpCode')} />
)}
<Form onSubmit={handleSubmit}>
<Form.Field>
<label htmlFor="totp-regen-password">{t('common.currentPassword')}</label>
<Input.Password
fluid
id="totp-regen-password"
value={password}
maxLength={256}
onChange={(_, { value }) => setPassword(value)}
/>
</Form.Field>
<Form.Field>
<label htmlFor="totp-regen-code">{t('common.totpCode')}</label>
<Input
fluid
id="totp-regen-code"
value={code}
maxLength={8}
placeholder="000000"
autoComplete="one-time-code"
onChange={(_, { value }) => setCode(value)}
/>
</Form.Field>
</Form>
</>
)}
{hasCodes && (
<>
<Message warning>
<Message.Header>{t('common.saveTheseCodes_title')}</Message.Header>
<p>{t('common.recoveryCodesIntro')}</p>
</Message>
<RecoveryCodesView codes={recoveryCodes} />
</>
)}
</Modal.Content>
<Modal.Actions>
{!hasCodes ? (
<>
<Button content={t('action.cancel')} floated="left" onClick={onClose} />
<Button
positive
content={t('action.regenerate')}
loading={isRegeneratingRecoveryCodes}
disabled={isRegeneratingRecoveryCodes || !password || !code}
onClick={handleSubmit}
/>
</>
) : (
<Button positive content={t('action.done')} onClick={onClose} />
)}
</Modal.Actions>
</Modal>
);
});
TotpRecoveryCodesModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default TotpRecoveryCodesModal;
@@ -0,0 +1,210 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { QRCodeSVG } from 'qrcode.react';
import { Button, Checkbox, Form, Message, Modal } from 'semantic-ui-react';
import { Input } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import RecoveryCodesView from './RecoveryCodesView';
import styles from './TotpSetupWizard.module.scss';
const STEPS = {
PASSWORD: 'password',
SCAN: 'scan',
VERIFY: 'verify',
CODES: 'codes',
};
const TotpSetupWizard = React.memo(({ onClose }) => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, setStep] = useState(STEPS.PASSWORD);
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [savedConfirmed, setSavedConfirmed] = useState(false);
const totpState = user.totpState || {};
const { setupSecret, setupProvisioningUri, isSettingUp, isEnabling, recoveryCodes, error } =
totpState;
useEffect(() => {
if (step === STEPS.SCAN && setupSecret && setupProvisioningUri) {
// Already initialized
}
}, [step, setupSecret, setupProvisioningUri]);
// Once setup succeeds, advance to scan
useEffect(() => {
if (step === STEPS.PASSWORD && setupSecret && setupProvisioningUri) {
setStep(STEPS.SCAN);
}
}, [step, setupSecret, setupProvisioningUri]);
// Once enable succeeds (recovery codes appear), advance to codes screen
useEffect(() => {
if (recoveryCodes && recoveryCodes.length > 0 && step === STEPS.VERIFY) {
setStep(STEPS.CODES);
}
}, [recoveryCodes, step]);
const handlePasswordSubmit = useCallback(() => {
if (!password) return;
dispatch(entryActions.setupCurrentUserTotp({ currentPassword: password }));
}, [dispatch, password]);
const handleProceedToVerify = useCallback(() => {
setStep(STEPS.VERIFY);
}, []);
const handleVerifySubmit = useCallback(() => {
const trimmed = code.replace(/\s+/g, '');
if (!trimmed) return;
dispatch(
entryActions.enableCurrentUserTotp({
currentPassword: password,
code: trimmed,
}),
);
}, [dispatch, code, password]);
const handleBackToScan = useCallback(() => {
setCode('');
setStep(STEPS.SCAN);
}, []);
const handleDone = useCallback(() => {
onClose();
}, [onClose]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
return (
<Modal open centered size="small" closeOnDimmerClick={false} onClose={handleCancel}>
<Modal.Header>{t('common.enable2fa_title')}</Modal.Header>
<Modal.Content>
{step === STEPS.PASSWORD && (
<Form onSubmit={handlePasswordSubmit}>
<p className={styles.intro}>{t('common.enterPasswordToContinue')}</p>
{error && error.message === 'Invalid current password' && (
<Message error content={t('common.invalidCurrentPassword')} />
)}
<Form.Field>
<label htmlFor="totp-current-password">{t('common.currentPassword')}</label>
<Input.Password
fluid
id="totp-current-password"
value={password}
maxLength={256}
onChange={(_, { value }) => setPassword(value)}
/>
</Form.Field>
</Form>
)}
{step === STEPS.SCAN && setupProvisioningUri && (
<div className={styles.scanStep}>
<p>{t('common.scanQrCodeWithApp')}</p>
<div className={styles.qrWrapper}>
<QRCodeSVG value={setupProvisioningUri} size={200} level="M" />
</div>
<p className={styles.secretLabel}>{t('common.orEnterSecretManually')}</p>
<code className={styles.secret}>{setupSecret}</code>
</div>
)}
{step === STEPS.VERIFY && (
<Form onSubmit={handleVerifySubmit}>
<p>{t('common.enterCodeFromApp')}</p>
{error && error.message === 'Invalid TOTP code' && (
<Message error content={t('common.invalidTotpCode')} />
)}
<Form.Field>
<Input
fluid
autoFocus
value={code}
maxLength={8}
placeholder="000000"
autoComplete="one-time-code"
className={styles.codeInput}
onChange={(_, { value }) => setCode(value)}
/>
</Form.Field>
</Form>
)}
{step === STEPS.CODES && recoveryCodes && (
<div className={styles.codesStep}>
<Message warning>
<Message.Header>{t('common.saveTheseCodes_title')}</Message.Header>
<p>{t('common.recoveryCodesIntro')}</p>
</Message>
<RecoveryCodesView codes={recoveryCodes} />
<Checkbox
label={t('common.confirmCodesSaved')}
checked={savedConfirmed}
className={styles.savedCheckbox}
onChange={(_, { checked }) => setSavedConfirmed(checked)}
/>
</div>
)}
</Modal.Content>
<Modal.Actions>
{step !== STEPS.CODES && (
<Button content={t('action.cancel')} floated="left" onClick={handleCancel} />
)}
{step === STEPS.PASSWORD && (
<Button
positive
content={t('action.continue')}
loading={isSettingUp}
disabled={isSettingUp || !password}
onClick={handlePasswordSubmit}
/>
)}
{step === STEPS.SCAN && (
<Button positive content={t('action.continue')} onClick={handleProceedToVerify} />
)}
{step === STEPS.VERIFY && (
<>
<Button content={t('action.back')} onClick={handleBackToScan} />
<Button
positive
content={t('action.verify')}
loading={isEnabling}
disabled={isEnabling || !code}
onClick={handleVerifySubmit}
/>
</>
)}
{step === STEPS.CODES && (
<Button
positive
content={t('action.done')}
disabled={!savedConfirmed}
onClick={handleDone}
/>
)}
</Modal.Actions>
</Modal>
);
});
TotpSetupWizard.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default TotpSetupWizard;
@@ -0,0 +1,59 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.intro {
color: var(--text-secondary);
margin-bottom: 12px;
}
.scanStep {
text-align: center;
}
.qrWrapper {
background: var(--surface-card);
border: 1px solid var(--grey-dddddd);
border-radius: 8px;
display: inline-block;
margin: 16px 0;
padding: 16px;
}
.secretLabel {
color: var(--text-secondary);
font-size: 13px;
margin-bottom: 6px;
}
.secret {
background: var(--surface-subtle);
border: 1px solid var(--grey-dddddd);
border-radius: 4px;
display: inline-block;
font-family: 'JetBrains Mono', 'Menlo', 'Courier New', monospace;
font-size: 14px;
letter-spacing: 1px;
padding: 6px 12px;
user-select: all;
}
.codeInput {
font-family: 'JetBrains Mono', 'Menlo', 'Courier New', monospace;
font-size: 18px;
letter-spacing: 2px;
text-align: center;
}
.codesStep {
display: flex;
flex-direction: column;
gap: 12px;
}
.savedCheckbox {
margin-top: 8px;
}
}
@@ -0,0 +1,142 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Header, Icon, List, Loader, Message } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import styles from './TrustedDevicesSection.module.scss';
const formatLastUsed = (iso, t) => {
if (!iso) return t('common.never');
return new Date(iso).toLocaleString();
};
const ICON_BY_DEVICE_TYPE = {
mobile: 'mobile alternate',
tablet: 'tablet alternate',
smarttv: 'tv',
wearable: 'heartbeat',
console: 'gamepad',
};
const getDeviceIcon = (device) => ICON_BY_DEVICE_TYPE[device.deviceType] || 'desktop';
const buildPrimaryLabel = (device, t) => {
if (device.label) return device.label;
// Mobile / tablet: vendor + model is usually the most recognizable ("Apple iPhone")
if (device.deviceVendor || device.deviceModel) {
const joined = [device.deviceVendor, device.deviceModel].filter(Boolean).join(' ');
if (joined) return joined;
}
// Desktop fallback: OS line
const osPart = [device.osName, device.osVersion].filter(Boolean).join(' ');
if (osPart) return osPart;
if (device.userAgentSummary) return device.userAgentSummary;
return t('common.unknownDevice');
};
const buildSecondaryLabel = (device) => {
const parts = [];
const hasMobileLabel = !!(device.deviceVendor || device.deviceModel);
// If we already showed OS as the primary label (desktop fallback), don't repeat it.
if (hasMobileLabel && (device.osName || device.osVersion)) {
parts.push([device.osName, device.osVersion].filter(Boolean).join(' '));
}
if (device.browserName) {
parts.push([device.browserName, device.browserVersion].filter(Boolean).join(' '));
}
return parts.filter(Boolean).join(' · ');
};
const TrustedDevicesSection = React.memo(() => {
const { items, isFetching, isFetched, deletingIds } = useSelector(
selectors.selectUserTrustedDevicesState,
);
const dispatch = useDispatch();
const [t] = useTranslation();
useEffect(() => {
dispatch(entryActions.fetchCurrentUserTrustedDevices());
}, [dispatch]);
const handleRevokeClick = useCallback(
(deviceId) => {
dispatch(entryActions.deleteCurrentUserTrustedDevice(deviceId));
},
[dispatch],
);
return (
<div className={styles.wrapper}>
<Header as="h4" className={styles.heading}>
{t('common.trustedDevices_title')}
</Header>
<p className={styles.hint}>{t('common.trustedDevicesHint')}</p>
{!isFetched && isFetching && <Loader active inline="centered" size="small" />}
{isFetched && items.length === 0 && <Message info content={t('common.noTrustedDevices')} />}
{items.length > 0 && (
<List divided relaxed className={styles.list}>
{items.map((device) => {
const isDeleting = deletingIds.includes(device.id);
const primary = buildPrimaryLabel(device, t);
const secondary = buildSecondaryLabel(device);
return (
<List.Item key={device.id}>
<List.Content floated="right">
<Button
basic
size="tiny"
icon="trash"
content={t('action.revoke')}
loading={isDeleting}
disabled={isDeleting}
onClick={() => handleRevokeClick(device.id)}
/>
</List.Content>
<List.Content>
<List.Header className={styles.deviceHeader}>
<Icon name={getDeviceIcon(device)} />
<span className={styles.devicePrimary}>{primary}</span>
</List.Header>
{secondary && (
<List.Description className={styles.deviceSecondary}>
{secondary}
</List.Description>
)}
<List.Description className={styles.meta}>
<span>
{t('common.lastUsed')}: {formatLastUsed(device.lastUsedAt, t)}
</span>
{' · '}
<span>
{t('common.expires')}: {new Date(device.expiresAt).toLocaleDateString()}
</span>
</List.Description>
</List.Content>
</List.Item>
);
})}
</List>
)}
</div>
);
});
export default TrustedDevicesSection;
@@ -0,0 +1,45 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
margin-top: 24px;
}
.heading {
margin-bottom: 8px !important;
}
.hint {
color: var(--text-tertiary);
margin-bottom: 12px;
}
.list {
margin: 0 !important;
}
.deviceHeader {
display: flex;
align-items: center;
gap: 6px;
}
.devicePrimary {
font-weight: 600;
}
.deviceSecondary {
color: var(--text-secondary) !important;
font-size: 13px;
margin-top: 2px !important;
}
.meta {
color: var(--text-tertiary) !important;
font-size: 12px;
margin-top: 4px;
}
}
@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import SecurityPane from './SecurityPane';
export default SecurityPane;
@@ -13,6 +13,7 @@ import { useClosableModal } from '../../../hooks';
import AccountPane from './AccountPane';
import PreferencesPane from './PreferencesPane';
import NotificationsPane from './NotificationsPane';
import SecurityPane from './SecurityPane';
const UserSettingsModal = React.memo(() => {
const dispatch = useDispatch();
@@ -43,6 +44,12 @@ const UserSettingsModal = React.memo(() => {
}),
render: () => <NotificationsPane />,
},
{
menuItem: t('common.security', {
context: 'title',
}),
render: () => <SecurityPane />,
},
];
return (
+1
View File
@@ -5,4 +5,5 @@
export default {
ACCEPT_TERMS: 'accept-terms',
VERIFY_TOTP: 'verify-totp',
};
+26
View File
@@ -27,6 +27,12 @@ export default {
AUTHENTICATE__SUCCESS: 'AUTHENTICATE__SUCCESS',
AUTHENTICATE__FAILURE: 'AUTHENTICATE__FAILURE',
AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR',
TOTP_VERIFY: 'TOTP_VERIFY',
TOTP_VERIFY__SUCCESS: 'TOTP_VERIFY__SUCCESS',
TOTP_VERIFY__FAILURE: 'TOTP_VERIFY__FAILURE',
TOTP_CHALLENGE_CANCEL: 'TOTP_CHALLENGE_CANCEL',
TOTP_CHALLENGE_CANCEL__SUCCESS: 'TOTP_CHALLENGE_CANCEL__SUCCESS',
TOTP_CHALLENGE_CANCEL__FAILURE: 'TOTP_CHALLENGE_CANCEL__FAILURE',
TERMS_ACCEPT: 'TERMS_ACCEPT',
TERMS_ACCEPT__SUCCESS: 'TERMS_ACCEPT__SUCCESS',
TERMS_ACCEPT__FAILURE: 'TERMS_ACCEPT__FAILURE',
@@ -80,6 +86,26 @@ export default {
/* Users */
USERS_RESET_HANDLE: 'USERS_RESET_HANDLE',
USER_TOTP_SETUP: 'USER_TOTP_SETUP',
USER_TOTP_SETUP__SUCCESS: 'USER_TOTP_SETUP__SUCCESS',
USER_TOTP_SETUP__FAILURE: 'USER_TOTP_SETUP__FAILURE',
USER_TOTP_SETUP_VALUE_CLEAR: 'USER_TOTP_SETUP_VALUE_CLEAR',
USER_TOTP_ENABLE: 'USER_TOTP_ENABLE',
USER_TOTP_ENABLE__SUCCESS: 'USER_TOTP_ENABLE__SUCCESS',
USER_TOTP_ENABLE__FAILURE: 'USER_TOTP_ENABLE__FAILURE',
USER_TOTP_DISABLE: 'USER_TOTP_DISABLE',
USER_TOTP_DISABLE__SUCCESS: 'USER_TOTP_DISABLE__SUCCESS',
USER_TOTP_DISABLE__FAILURE: 'USER_TOTP_DISABLE__FAILURE',
USER_TOTP_RECOVERY_CODES_REGENERATE: 'USER_TOTP_RECOVERY_CODES_REGENERATE',
USER_TOTP_RECOVERY_CODES_REGENERATE__SUCCESS: 'USER_TOTP_RECOVERY_CODES_REGENERATE__SUCCESS',
USER_TOTP_RECOVERY_CODES_REGENERATE__FAILURE: 'USER_TOTP_RECOVERY_CODES_REGENERATE__FAILURE',
USER_TOTP_RECOVERY_CODES_CLEAR: 'USER_TOTP_RECOVERY_CODES_CLEAR',
USER_TRUSTED_DEVICES_FETCH: 'USER_TRUSTED_DEVICES_FETCH',
USER_TRUSTED_DEVICES_FETCH__SUCCESS: 'USER_TRUSTED_DEVICES_FETCH__SUCCESS',
USER_TRUSTED_DEVICES_FETCH__FAILURE: 'USER_TRUSTED_DEVICES_FETCH__FAILURE',
USER_TRUSTED_DEVICE_DELETE: 'USER_TRUSTED_DEVICE_DELETE',
USER_TRUSTED_DEVICE_DELETE__SUCCESS: 'USER_TRUSTED_DEVICE_DELETE__SUCCESS',
USER_TRUSTED_DEVICE_DELETE__FAILURE: 'USER_TRUSTED_DEVICE_DELETE__FAILURE',
USER_CREATE: 'USER_CREATE',
USER_CREATE__SUCCESS: 'USER_CREATE__SUCCESS',
USER_CREATE__FAILURE: 'USER_CREATE__FAILURE',
+11
View File
@@ -21,6 +21,8 @@ export default {
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`,
TOTP_VERIFY: `${PREFIX}/TOTP_VERIFY`,
TOTP_CHALLENGE_CANCEL: `${PREFIX}/TOTP_CHALLENGE_CANCEL`,
TERMS_ACCEPT: `${PREFIX}/TERMS_ACCEPT`,
TERMS_CANCEL: `${PREFIX}/TERMS_CANCEL`,
TERMS_LANGUAGE_UPDATE: `${PREFIX}/TERMS_LANGUAGE_UPDATE`,
@@ -60,6 +62,15 @@ export default {
USER_CREATE_ERROR_CLEAR: `${PREFIX}/USER_CREATE_ERROR_CLEAR`,
USER_UPDATE: `${PREFIX}/USER_UPDATE`,
CURRENT_USER_UPDATE: `${PREFIX}/CURRENT_USER_UPDATE`,
CURRENT_USER_TOTP_SETUP: `${PREFIX}/CURRENT_USER_TOTP_SETUP`,
CURRENT_USER_TOTP_SETUP_VALUE_CLEAR: `${PREFIX}/CURRENT_USER_TOTP_SETUP_VALUE_CLEAR`,
CURRENT_USER_TOTP_ENABLE: `${PREFIX}/CURRENT_USER_TOTP_ENABLE`,
CURRENT_USER_TOTP_DISABLE: `${PREFIX}/CURRENT_USER_TOTP_DISABLE`,
USER_TOTP_DISABLE: `${PREFIX}/USER_TOTP_DISABLE`,
CURRENT_USER_TOTP_RECOVERY_CODES_REGENERATE: `${PREFIX}/CURRENT_USER_TOTP_RECOVERY_CODES_REGENERATE`,
CURRENT_USER_TOTP_RECOVERY_CODES_CLEAR: `${PREFIX}/CURRENT_USER_TOTP_RECOVERY_CODES_CLEAR`,
CURRENT_USER_TRUSTED_DEVICES_FETCH: `${PREFIX}/CURRENT_USER_TRUSTED_DEVICES_FETCH`,
CURRENT_USER_TRUSTED_DEVICE_DELETE: `${PREFIX}/CURRENT_USER_TRUSTED_DEVICE_DELETE`,
USER_UPDATE_HANDLE: `${PREFIX}/USER_UPDATE_HANDLE`,
CURRENT_USER_LANGUAGE_UPDATE: `${PREFIX}/CURRENT_USER_LANGUAGE_UPDATE`,
USER_EMAIL_UPDATE: `${PREFIX}/USER_EMAIL_UPDATE`,
+14
View File
@@ -36,10 +36,24 @@ const updateTermsLanguage = (value) => ({
},
});
const verifyTotp = (data) => ({
type: EntryActionTypes.TOTP_VERIFY,
payload: {
data,
},
});
const cancelTotpChallenge = () => ({
type: EntryActionTypes.TOTP_CHALLENGE_CANCEL,
payload: {},
});
export default {
authenticate,
clearAuthenticateError,
acceptTerms,
cancelTerms,
updateTermsLanguage,
verifyTotp,
cancelTotpChallenge,
};
+67
View File
@@ -175,6 +175,64 @@ const clearUserApiKeyValue = (id) => ({
},
});
const setupCurrentUserTotp = (data) => ({
type: EntryActionTypes.CURRENT_USER_TOTP_SETUP,
payload: {
data,
},
});
const clearCurrentUserTotpSetupValue = () => ({
type: EntryActionTypes.CURRENT_USER_TOTP_SETUP_VALUE_CLEAR,
payload: {},
});
const enableCurrentUserTotp = (data) => ({
type: EntryActionTypes.CURRENT_USER_TOTP_ENABLE,
payload: {
data,
},
});
const disableCurrentUserTotp = (data) => ({
type: EntryActionTypes.CURRENT_USER_TOTP_DISABLE,
payload: {
data,
},
});
const disableUserTotp = (id, data) => ({
type: EntryActionTypes.USER_TOTP_DISABLE,
payload: {
id,
data,
},
});
const regenerateCurrentUserTotpRecoveryCodes = (data) => ({
type: EntryActionTypes.CURRENT_USER_TOTP_RECOVERY_CODES_REGENERATE,
payload: {
data,
},
});
const clearCurrentUserTotpRecoveryCodes = () => ({
type: EntryActionTypes.CURRENT_USER_TOTP_RECOVERY_CODES_CLEAR,
payload: {},
});
const fetchCurrentUserTrustedDevices = () => ({
type: EntryActionTypes.CURRENT_USER_TRUSTED_DEVICES_FETCH,
payload: {},
});
const deleteCurrentUserTrustedDevice = (deviceId) => ({
type: EntryActionTypes.CURRENT_USER_TRUSTED_DEVICE_DELETE,
payload: {
deviceId,
},
});
const deleteUser = (id) => ({
type: EntryActionTypes.USER_DELETE,
payload: {
@@ -284,6 +342,15 @@ export default {
createUserApiKey,
deleteUserApiKey,
clearUserApiKeyValue,
setupCurrentUserTotp,
clearCurrentUserTotpSetupValue,
enableCurrentUserTotp,
disableCurrentUserTotp,
disableUserTotp,
regenerateCurrentUserTotpRecoveryCodes,
clearCurrentUserTotpRecoveryCodes,
fetchCurrentUserTrustedDevices,
deleteCurrentUserTrustedDevice,
deleteUser,
handleUserDelete,
addUserToCard,
+54
View File
@@ -139,6 +139,7 @@ export default {
closed: 'Geschlossen',
color: 'Farbe',
comments: 'Kommentare',
confirmCodesSaved: 'Ich habe diese Codes an einem sicheren Ort gespeichert.',
contentExceedsLimit: 'Inhalt überschreitet {{limit}}',
contentOfThisAttachmentIsTooBigToDisplay:
'Der Inhalt dieses Anhangs ist zu groß für die Anzeige.',
@@ -159,9 +160,52 @@ export default {
customFields_title: 'Feldgruppen',
customerPanel_title: 'Kundenpanel',
dangerZone_title: 'Gefahrenbereich',
disable2fa_title: 'Zwei-Faktor-Authentifizierung deaktivieren',
disable2faWarning:
'Sie müssen Ihr aktuelles Passwort und einen TOTP-Code bestätigen. Bestehende Sitzungen bleiben angemeldet.',
discoverPlankaPro: '✨ Mehr Features für eure Boards: PLANKA Pro entdecken',
discoverPlankaPro_title: 'PLANKA Pro entdecken',
dismissProBannerFor30Days: 'Für 30 Tage schließen',
enable2fa_title: 'Zwei-Faktor-Authentifizierung aktivieren',
enabledOn: 'Aktiviert am {{date}}',
enterCodeFromApp: 'Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.',
enterPasswordToContinue: 'Bestätigen Sie Ihr aktuelles Passwort, um fortzufahren.',
enterTotpOrRecoveryCode:
'Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App oder einen Ihrer Wiederherstellungscodes (xxxxx-xxxxx) ein.',
expires: 'Läuft ab',
invalidTotpCode: 'Ungültiger TOTP- oder Wiederherstellungscode',
lastUsed: 'Zuletzt verwendet',
never: 'Nie',
noTrustedDevices: 'Keine vertrauenswürdigen Geräte.',
orEnterSecretManually: 'Oder geben Sie dieses Geheimnis manuell ein:',
recoveryCodesExhausted:
'Alle Wiederherstellungscodes wurden verwendet. Erzeugen Sie neue Codes, um einen Fallback zu haben.',
recoveryCodesIntro:
'Bewahren Sie diese Codes sicher auf — jeder kann einmal verwendet werden, falls Sie keinen Zugang zu Ihrer Authenticator-App haben.',
recoveryCodesLow_one:
'Nur noch {{count}} Wiederherstellungscode verfügbar. Bitte bald neu erzeugen.',
recoveryCodesLow_other:
'Nur noch {{count}} Wiederherstellungscodes verfügbar. Bitte bald neu erzeugen.',
regenerateRecoveryCodes_title: 'Wiederherstellungscodes neu generieren',
regenerateRecoveryCodesIntro:
'Beim Generieren neuer Codes werden alle bestehenden Wiederherstellungscodes ungültig.',
reset2fa_title: '2FA zurücksetzen',
reset2faWarning:
'Beim Zurücksetzen der 2FA wird der Benutzer aus allen Sitzungen abgemeldet und seine TOTP-Einrichtung entfernt. Er muss sie bei der nächsten Anmeldung neu einrichten.',
saveTheseCodes_title: 'Diese Wiederherstellungscodes speichern',
scanQrCodeWithApp:
'Scannen Sie diesen QR-Code mit einer Authenticator-App wie Google Authenticator oder Authy.',
security_title: 'Sicherheit',
totpCode: 'TOTP-Code',
totpOrRecoveryCode: 'TOTP- oder Wiederherstellungscode',
trustedDevices_title: 'Vertrauenswürdige Browser',
trustedDevicesHint:
'Als vertrauenswürdig markierte Browser umgehen den zweiten Faktor für 30 Tage. Entfernen Sie alle, die Sie nicht mehr kennen.',
twoFactor_enabled: 'Zwei-Faktor-Authentifizierung ist aktiviert.',
twoFactor_intro:
'Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu, indem Sie einen zeitbasierten Einmalcode aus einer Authenticator-App verwenden.',
twoFactorAuthentication: 'Zwei-Faktor-Authentifizierung',
unknownDevice: 'Unbekanntes Gerät',
upgradeTeamToPro_title: 'Team auf Pro upgraden',
proFeatureCalendar: '✨ Kalenderansicht für eure Boards',
proFeatureRecurringCards: '✨ Wiederkehrende Karten',
@@ -422,8 +466,10 @@ export default {
archiveCards: 'Karten archivieren',
archiveCards_title: 'Karten archivieren',
assignAsOwner: 'Als Eigentümer zuweisen',
back: 'Zurück',
cancel: 'Abbrechen',
copy: 'Kopieren',
copyAll: 'Alle kopieren',
copyCard_title: 'Karte Kopieren',
createApiKey: 'API-Schlüssel erstellen',
createBoard: 'Arbeitsbereich erstellen',
@@ -463,7 +509,9 @@ export default {
deleteUser: 'Benutzer löschen',
deleteUser_title: 'Benutzer löschen',
deleteWebhook: 'Webhook löschen',
disable2fa: '2FA deaktivieren',
dismissAll: 'Alle verwerfen',
done: 'Fertig',
download: 'Herunterladen',
duplicateCard_title: 'Karte duplizieren',
edit: 'Bearbeiten',
@@ -483,6 +531,7 @@ export default {
editUsername_title: 'Benutzername ändern',
emptyTrash: 'Papierkorb leeren',
emptyTrash_title: 'Papierkorb leeren',
enable2fa: '2FA aktivieren',
import: 'Import',
join: 'Beitreten',
leave: 'Verlassen',
@@ -497,7 +546,9 @@ export default {
move: 'Verschieben',
moveCard_title: 'Karte bewegen',
moveList_title: 'Liste verschieben',
regenerate: 'Neu generieren',
regenerateApiKey: 'API-Schlüssel neu generieren',
regenerateRecoveryCodes: 'Wiederherstellungscodes neu generieren',
remove: 'Löschen',
removeAssignee: 'Zuständigen entfernen',
removeColor: 'Farbe löschen',
@@ -506,8 +557,10 @@ export default {
removeFromProject: 'Vom Projekt entfernen',
removeManager: 'Projektleiter entfernen',
removeMember: 'Mitglied entfernen',
reset2fa: '2FA zurücksetzen',
restoreToList: 'Wiederherstellen in {{list}}',
returnToBoard: 'Zurück zum Arbeitsbereich',
revoke: 'Widerrufen',
save: 'Speichern',
sendTestEmail: 'Test-E-Mail senden',
showActive: 'Aktive anzeigen',
@@ -524,6 +577,7 @@ export default {
unsubscribe: 'De-abonnieren',
uploadNewAvatar: 'Neuen Avatar hochladen',
uploadNewImage: 'Neues Bild hochladen',
verify: 'Bestätigen',
},
},
};
+7
View File
@@ -6,9 +6,12 @@ export default {
'Admin-Anmeldung erforderlich zur Initialisierung der Instanz',
emailAlreadyInUse: 'E-mail Adresse wird bereits benutzt',
emailOrUsername: 'E-Mail-Adresse oder Benutzername',
enterTotpOrRecoveryCode:
'Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App oder einen Ihrer Wiederherstellungscodes (xxxxx-xxxxx) ein.',
invalidCredentials: 'Ungültige Anmeldeinformationen',
invalidEmailOrUsername: 'Ungültige E-Mail-Adresse oder Benutzername',
invalidPassword: 'Ungültiges Passwort',
invalidTotpCode: 'Ungültiger TOTP- oder Wiederherstellungscode',
logIn_title: 'Anmelden',
noInternetConnection: 'Keine Internetverbindung',
or: 'Oder',
@@ -16,6 +19,9 @@ export default {
password: 'Passwort',
poweredByPlanka: 'Powered by <1>PLANKA</1>',
serverConnectionFailed: 'Serververbindung fehlgeschlagen',
totpSessionExpired: 'TOTP-Sitzung abgelaufen. Bitte erneut einloggen.',
trustThisBrowser: 'Diesen Browser für 30 Tage merken',
twoFactorRequired_title: 'Zwei-Faktor-Authentifizierung erforderlich',
unknownError: 'Unbekannter Fehler, bitte später erneut versuchen',
usernameAlreadyInUse: 'Benutzername wird bereits verwendet',
whoops_title: 'Hoppla!',
@@ -27,6 +33,7 @@ export default {
goBack: 'Zurück gehen',
goHome: 'Zur Startseite',
logIn: 'Einloggen',
verify: 'Bestätigen',
},
},
};
+53
View File
@@ -120,6 +120,7 @@ export default {
closed: 'Closed',
color: 'Color',
comments: 'Comments',
confirmCodesSaved: 'I have saved these codes in a safe place.',
contentExceedsLimit: 'Content exceeds {{limit}}',
contentOfThisAttachmentIsTooBigToDisplay: 'Content of this attachment is too big to display.',
copy_inline: 'copy',
@@ -138,9 +139,51 @@ export default {
customFields_title: 'Custom Fields',
customerPanel_title: 'Customer Panel',
dangerZone_title: 'Danger Zone',
disable2fa_title: 'Disable Two-Factor Authentication',
disable2faWarning:
'You will need to confirm your current password and a TOTP code. Existing sessions stay signed in.',
discoverPlankaPro: '✨ More features for your boards: Discover PLANKA Pro',
discoverPlankaPro_title: 'Discover PLANKA Pro',
dismissProBannerFor30Days: 'Dismiss for 30 days',
enable2fa_title: 'Enable Two-Factor Authentication',
enabledOn: 'Enabled on {{date}}',
enterCodeFromApp: 'Enter the 6-digit code from your authenticator app.',
enterPasswordToContinue: 'Confirm your current password to continue.',
enterTotpOrRecoveryCode:
'Enter the 6-digit code from your authenticator app, or one of your recovery codes (xxxxx-xxxxx).',
expires: 'Expires',
invalidTotpCode: 'Invalid TOTP or recovery code',
lastUsed: 'Last used',
never: 'Never',
noTrustedDevices: 'No trusted devices.',
orEnterSecretManually: 'Or enter this secret manually:',
recoveryCodesExhausted:
'All recovery codes have been used. Regenerate a new set to keep a fallback in case you lose your authenticator app.',
recoveryCodesIntro:
'Store these codes in a safe place — each can be used once if you lose access to your authenticator app.',
recoveryCodesLow_one: 'Only {{count}} recovery code remaining. Consider regenerating soon.',
recoveryCodesLow_other:
'Only {{count}} recovery codes remaining. Consider regenerating soon.',
regenerateRecoveryCodes_title: 'Regenerate Recovery Codes',
regenerateRecoveryCodesIntro:
'Generating new codes will invalidate all existing recovery codes.',
reset2fa_title: 'Reset 2FA',
reset2faWarning:
'Resetting 2FA signs the user out of all sessions and removes their TOTP setup. They will need to set it up again on next login.',
saveTheseCodes_title: 'Save These Recovery Codes',
scanQrCodeWithApp:
'Scan this QR code with an authenticator app such as Google Authenticator or Authy.',
security_title: 'Security',
totpCode: 'TOTP code',
totpOrRecoveryCode: 'TOTP or recovery code',
trustedDevices_title: 'Trusted Browsers',
trustedDevicesHint:
'Browsers that have been marked as trusted skip the second factor for 30 days. Revoke any you no longer recognize.',
twoFactor_enabled: 'Two-factor authentication is enabled.',
twoFactor_intro:
'Add an extra layer of security to your account using a time-based one-time code from an authenticator app.',
twoFactorAuthentication: 'Two-Factor Authentication',
unknownDevice: 'Unknown device',
upgradeTeamToPro_title: 'Upgrade Team to Pro',
proFeatureCalendar: '✨ Calendar View for your boards',
proFeatureRecurringCards: '✨ Recurring Cards',
@@ -397,8 +440,10 @@ export default {
archiveCards: 'Archive cards',
archiveCards_title: 'Archive Cards',
assignAsOwner: 'Assign as owner',
back: 'Back',
cancel: 'Cancel',
copy: 'Copy',
copyAll: 'Copy all',
copyCard_title: 'Copy Card',
createApiKey: 'Create API key',
createBoard: 'Create board',
@@ -438,7 +483,9 @@ export default {
deleteUser: 'Delete user',
deleteUser_title: 'Delete User',
deleteWebhook: 'Delete webhook',
disable2fa: 'Disable 2FA',
dismissAll: 'Dismiss all',
done: 'Done',
download: 'Download',
duplicateCard_title: 'Duplicate Card',
edit: 'Edit',
@@ -458,6 +505,7 @@ export default {
editUsername_title: 'Edit Username',
emptyTrash: 'Empty trash',
emptyTrash_title: 'Empty Trash',
enable2fa: 'Enable 2FA',
import: 'Import',
join: 'Join',
leave: 'Leave',
@@ -472,7 +520,9 @@ export default {
move: 'Move',
moveCard_title: 'Move Card',
moveList_title: 'Move List',
regenerate: 'Regenerate',
regenerateApiKey: 'Regenerate API key',
regenerateRecoveryCodes: 'Regenerate recovery codes',
remove: 'Remove',
removeAssignee: 'Remove assignee',
removeColor: 'Remove color',
@@ -481,8 +531,10 @@ export default {
removeFromProject: 'Remove from project',
removeManager: 'Remove manager',
removeMember: 'Remove member',
reset2fa: 'Reset 2FA',
restoreToList: 'Restore to {{list}}',
returnToBoard: 'Return to board',
revoke: 'Revoke',
save: 'Save',
sendTestEmail: 'Send test email',
showActive: 'Show active',
@@ -499,6 +551,7 @@ export default {
unsubscribe: 'Unsubscribe',
uploadNewAvatar: 'Upload new avatar',
uploadNewImage: 'Upload new image',
verify: 'Verify',
},
},
};
+7
View File
@@ -5,9 +5,12 @@ export default {
adminLoginRequiredToInitializeInstance: 'Admin login required to initialize instance',
emailAlreadyInUse: 'E-mail already in use',
emailOrUsername: 'E-mail or username',
enterTotpOrRecoveryCode:
'Enter the 6-digit code from your authenticator app, or one of your recovery codes (xxxxx-xxxxx).',
invalidCredentials: 'Invalid credentials',
invalidEmailOrUsername: 'Invalid e-mail or username',
invalidPassword: 'Invalid password',
invalidTotpCode: 'Invalid TOTP or recovery code',
logIn_title: 'Log In',
noInternetConnection: 'No internet connection',
or: 'Or',
@@ -15,6 +18,9 @@ export default {
password: 'Password',
poweredByPlanka: 'Powered by <1>PLANKA</1>',
serverConnectionFailed: 'Server connection failed',
totpSessionExpired: 'TOTP session expired. Please log in again.',
trustThisBrowser: 'Trust this browser for 30 days',
twoFactorRequired_title: 'Two-Factor Authentication Required',
unknownError: 'Unknown error, try again later',
usernameAlreadyInUse: 'Username already in use',
whoops_title: 'Whoops!',
@@ -26,6 +32,7 @@ export default {
goBack: 'Go back',
goHome: 'Go home',
logIn: 'Log in',
verify: 'Verify',
},
},
};
+171
View File
@@ -60,6 +60,17 @@ const filterProjectModels = (projectModels, search, isHidden) => {
return filteredProjectModels;
};
const DEFAULT_TOTP_STATE = {
setupSecret: null,
setupProvisioningUri: null,
recoveryCodes: null,
isSettingUp: false,
isEnabling: false,
isDisabling: false,
isRegeneratingRecoveryCodes: false,
error: null,
};
export default class extends BaseModel {
static modelName = 'User';
@@ -78,11 +89,17 @@ export default class extends BaseModel {
subscribeToCardWhenCommenting: attr(),
turnOffRecentCardHighlighting: attr(),
isDefaultAdmin: attr(),
isTotpEnabled: attr(),
totpEnabledAt: attr(),
totpRecoveryCodesRemaining: attr(),
isDeactivated: attr(),
lockedFieldNames: attr(),
isAvatarUpdating: attr({
getDefault: () => false,
}),
totpState: attr({
getDefault: () => DEFAULT_TOTP_STATE,
}),
emailUpdateForm: attr({
getDefault: () => DEFAULT_EMAIL_UPDATE_FORM,
}),
@@ -363,6 +380,160 @@ export default class extends BaseModel {
break;
}
case ActionTypes.USER_TOTP_SETUP: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...DEFAULT_TOTP_STATE,
isSettingUp: true,
};
}
break;
}
case ActionTypes.USER_TOTP_SETUP__SUCCESS: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...DEFAULT_TOTP_STATE,
setupSecret: payload.setup.secret,
setupProvisioningUri: payload.setup.provisioningUri,
};
}
break;
}
case ActionTypes.USER_TOTP_SETUP__FAILURE: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...DEFAULT_TOTP_STATE,
error: payload.error,
};
}
break;
}
case ActionTypes.USER_TOTP_SETUP_VALUE_CLEAR: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = DEFAULT_TOTP_STATE;
}
break;
}
case ActionTypes.USER_TOTP_ENABLE: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
isEnabling: true,
error: null,
};
}
break;
}
case ActionTypes.USER_TOTP_ENABLE__SUCCESS:
User.withId(payload.user.id).update({
...payload.user,
totpState: {
...DEFAULT_TOTP_STATE,
recoveryCodes: payload.recoveryCodes,
},
});
break;
case ActionTypes.USER_TOTP_ENABLE__FAILURE: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
isEnabling: false,
error: payload.error,
};
}
break;
}
case ActionTypes.USER_TOTP_DISABLE: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
isDisabling: true,
error: null,
};
}
break;
}
case ActionTypes.USER_TOTP_DISABLE__SUCCESS:
User.withId(payload.user.id).update({
...payload.user,
totpState: DEFAULT_TOTP_STATE,
});
break;
case ActionTypes.USER_TOTP_DISABLE__FAILURE: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
isDisabling: false,
error: payload.error,
};
}
break;
}
case ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
isRegeneratingRecoveryCodes: true,
error: null,
};
}
break;
}
case ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE__SUCCESS: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
isRegeneratingRecoveryCodes: false,
recoveryCodes: payload.recoveryCodes,
};
}
break;
}
case ActionTypes.USER_TOTP_RECOVERY_CODES_REGENERATE__FAILURE: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
isRegeneratingRecoveryCodes: false,
error: payload.error,
};
}
break;
}
case ActionTypes.USER_TOTP_RECOVERY_CODES_CLEAR: {
const userModel = User.withId(payload.id);
if (userModel) {
userModel.totpState = {
...userModel.totpState,
recoveryCodes: null,
};
}
break;
}
default:
}
}
+1
View File
@@ -15,6 +15,7 @@ const initialState = {
export default (state = initialState, { type, payload }) => {
switch (type) {
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.TOTP_VERIFY__SUCCESS:
case ActionTypes.TERMS_ACCEPT__SUCCESS:
return {
...state,
+1
View File
@@ -33,6 +33,7 @@ export default (state = initialState, { type, payload }) => {
bootstrap: payload.bootstrap,
};
case ActionTypes.AUTHENTICATE__SUCCESS:
case ActionTypes.TOTP_VERIFY__SUCCESS:
case ActionTypes.TERMS_ACCEPT__SUCCESS:
return {
...state,
@@ -4,6 +4,7 @@
*/
import ActionTypes from '../../constants/ActionTypes';
import AccessTokenSteps from '../../constants/AccessTokenSteps';
const initialState = {
data: {
@@ -20,6 +21,11 @@ const initialState = {
isCancelling: false,
isLanguageUpdating: false,
},
totpForm: {
isSubmitting: false,
isCancelling: false,
error: null,
},
};
// eslint-disable-next-line default-param-last
@@ -38,6 +44,9 @@ export default (state = initialState, { type, payload }) => {
case ActionTypes.TERMS_ACCEPT__SUCCESS:
case ActionTypes.TERMS_CANCEL__SUCCESS:
case ActionTypes.TERMS_CANCEL__FAILURE:
case ActionTypes.TOTP_VERIFY__SUCCESS:
case ActionTypes.TOTP_CHALLENGE_CANCEL__SUCCESS:
case ActionTypes.TOTP_CHALLENGE_CANCEL__FAILURE:
return initialState;
case ActionTypes.AUTHENTICATE__FAILURE:
if (payload.terms) {
@@ -53,11 +62,49 @@ export default (state = initialState, { type, payload }) => {
};
}
if (payload.error && payload.error.step === AccessTokenSteps.VERIFY_TOTP) {
return {
...state,
data: initialState.data,
isSubmitting: false,
pendingToken: payload.error.pendingToken,
step: payload.error.step,
totpForm: initialState.totpForm,
};
}
return {
...state,
isSubmitting: false,
error: payload.error,
};
case ActionTypes.TOTP_VERIFY:
return {
...state,
totpForm: {
...state.totpForm,
isSubmitting: true,
error: null,
},
};
case ActionTypes.TOTP_VERIFY__FAILURE:
return {
...state,
totpForm: {
...state.totpForm,
isSubmitting: false,
error: payload.error,
},
};
case ActionTypes.TOTP_CHALLENGE_CANCEL:
return {
...state,
pendingToken: null,
totpForm: {
...state.totpForm,
isCancelling: true,
},
};
case ActionTypes.AUTHENTICATE_ERROR_CLEAR:
return {
...state,
+2
View File
@@ -9,10 +9,12 @@ import authenticateForm from './authenticate-form';
import userCreateForm from './user-create-form';
import projectCreateForm from './project-create-form';
import smtpTestState from './smtp-test-state';
import userTrustedDevicesState from './user-trusted-devices-state';
export default combineReducers({
authenticateForm,
userCreateForm,
projectCreateForm,
smtpTestState,
userTrustedDevicesState,
});
@@ -0,0 +1,59 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ActionTypes from '../../constants/ActionTypes';
const initialState = {
items: [],
isFetching: false,
isFetched: false,
deletingIds: [],
error: null,
};
// eslint-disable-next-line default-param-last
export default (state = initialState, { type, payload }) => {
switch (type) {
case ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE:
return initialState;
case ActionTypes.USER_TRUSTED_DEVICES_FETCH:
return {
...state,
isFetching: true,
error: null,
};
case ActionTypes.USER_TRUSTED_DEVICES_FETCH__SUCCESS:
return {
...state,
isFetching: false,
isFetched: true,
items: payload.devices,
};
case ActionTypes.USER_TRUSTED_DEVICES_FETCH__FAILURE:
return {
...state,
isFetching: false,
error: payload.error,
};
case ActionTypes.USER_TRUSTED_DEVICE_DELETE:
return {
...state,
deletingIds: [...state.deletingIds, payload.deviceId],
};
case ActionTypes.USER_TRUSTED_DEVICE_DELETE__SUCCESS:
return {
...state,
items: state.items.filter((d) => d.id !== payload.device.id),
deletingIds: state.deletingIds.filter((id) => id !== payload.device.id),
};
case ActionTypes.USER_TRUSTED_DEVICE_DELETE__FAILURE:
return {
...state,
deletingIds: state.deletingIds.filter((id) => id !== payload.deviceId),
};
default:
return state;
}
};
+144
View File
@@ -388,6 +388,136 @@ export function* clearUserApiKeyValue(id) {
yield put(actions.clearUserApiKeyValue(id));
}
export function* setupUserTotp(id, data) {
yield put(actions.setupUserTotp(id));
let setup;
try {
({ item: setup } = yield call(request, api.setupUserTotp, id, data));
} catch (error) {
yield put(actions.setupUserTotp.failure(id, error));
return;
}
yield put(actions.setupUserTotp.success(id, setup));
}
export function* setupCurrentUserTotp(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(setupUserTotp, currentUserId, data);
}
export function* clearCurrentUserTotpSetupValue() {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield put(actions.clearUserTotpSetupValue(currentUserId));
}
export function* enableUserTotp(id, data) {
yield put(actions.enableUserTotp(id));
let user;
let recoveryCodes;
try {
({
item: user,
included: { recoveryCodes },
} = yield call(request, api.enableUserTotp, id, data));
} catch (error) {
yield put(actions.enableUserTotp.failure(id, error));
return;
}
yield put(actions.enableUserTotp.success(user, recoveryCodes));
}
export function* enableCurrentUserTotp(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(enableUserTotp, currentUserId, data);
}
export function* disableUserTotp(id, data) {
yield put(actions.disableUserTotp(id));
let user;
try {
({ item: user } = yield call(request, api.disableUserTotp, id, data));
} catch (error) {
yield put(actions.disableUserTotp.failure(id, error));
return;
}
yield put(actions.disableUserTotp.success(user));
}
export function* disableCurrentUserTotp(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(disableUserTotp, currentUserId, data);
}
export function* regenerateUserTotpRecoveryCodes(id, data) {
yield put(actions.regenerateUserTotpRecoveryCodes(id));
let recoveryCodes;
try {
({
included: { recoveryCodes },
} = yield call(request, api.regenerateUserTotpRecoveryCodes, id, data));
} catch (error) {
yield put(actions.regenerateUserTotpRecoveryCodes.failure(id, error));
return;
}
yield put(actions.regenerateUserTotpRecoveryCodes.success(id, recoveryCodes));
}
export function* regenerateCurrentUserTotpRecoveryCodes(data) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(regenerateUserTotpRecoveryCodes, currentUserId, data);
}
export function* clearCurrentUserTotpRecoveryCodes() {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield put(actions.clearUserTotpRecoveryCodes(currentUserId));
}
export function* fetchUserTrustedDevices(id) {
yield put(actions.fetchUserTrustedDevices(id));
let devices;
try {
({ items: devices } = yield call(request, api.getUserTrustedDevices, id));
} catch (error) {
yield put(actions.fetchUserTrustedDevices.failure(id, error));
return;
}
yield put(actions.fetchUserTrustedDevices.success(id, devices));
}
export function* fetchCurrentUserTrustedDevices() {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(fetchUserTrustedDevices, currentUserId);
}
export function* deleteUserTrustedDevice(id, deviceId) {
yield put(actions.deleteUserTrustedDevice(id, deviceId));
let device;
try {
({ item: device } = yield call(request, api.deleteUserTrustedDevice, id, deviceId));
} catch (error) {
yield put(actions.deleteUserTrustedDevice.failure(id, deviceId, error));
return;
}
yield put(actions.deleteUserTrustedDevice.success(id, device));
}
export function* deleteCurrentUserTrustedDevice(deviceId) {
const currentUserId = yield select(selectors.selectCurrentUserId);
yield call(deleteUserTrustedDevice, currentUserId, deviceId);
}
export function* deleteUser(id) {
yield put(actions.deleteUser(id));
@@ -528,6 +658,20 @@ export default {
createUserApiKey,
deleteUserApiKey,
clearUserApiKeyValue,
setupUserTotp,
setupCurrentUserTotp,
clearCurrentUserTotpSetupValue,
enableUserTotp,
enableCurrentUserTotp,
disableUserTotp,
disableCurrentUserTotp,
regenerateUserTotpRecoveryCodes,
regenerateCurrentUserTotpRecoveryCodes,
clearCurrentUserTotpRecoveryCodes,
fetchUserTrustedDevices,
fetchCurrentUserTrustedDevices,
deleteUserTrustedDevice,
deleteCurrentUserTrustedDevice,
deleteUser,
handleUserDelete,
addUserToCard,
+28
View File
@@ -79,6 +79,34 @@ export default function* usersWatchers() {
takeEvery(EntryActionTypes.USER_API_KEY_VALUE_CLEAR, ({ payload: { id } }) =>
services.clearUserApiKeyValue(id),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_SETUP, ({ payload: { data } }) =>
services.setupCurrentUserTotp(data),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_SETUP_VALUE_CLEAR, () =>
services.clearCurrentUserTotpSetupValue(),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_ENABLE, ({ payload: { data } }) =>
services.enableCurrentUserTotp(data),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_DISABLE, ({ payload: { data } }) =>
services.disableCurrentUserTotp(data),
),
takeEvery(EntryActionTypes.USER_TOTP_DISABLE, ({ payload: { id, data } }) =>
services.disableUserTotp(id, data),
),
takeEvery(
EntryActionTypes.CURRENT_USER_TOTP_RECOVERY_CODES_REGENERATE,
({ payload: { data } }) => services.regenerateCurrentUserTotpRecoveryCodes(data),
),
takeEvery(EntryActionTypes.CURRENT_USER_TOTP_RECOVERY_CODES_CLEAR, () =>
services.clearCurrentUserTotpRecoveryCodes(),
),
takeEvery(EntryActionTypes.CURRENT_USER_TRUSTED_DEVICES_FETCH, () =>
services.fetchCurrentUserTrustedDevices(),
),
takeEvery(EntryActionTypes.CURRENT_USER_TRUSTED_DEVICE_DELETE, ({ payload: { deviceId } }) =>
services.deleteCurrentUserTrustedDevice(deviceId),
),
takeEvery(EntryActionTypes.USER_DELETE, ({ payload: { id } }) => services.deleteUser(id)),
takeEvery(EntryActionTypes.USER_DELETE_HANDLE, ({ payload: { user } }) =>
services.handleUserDelete(user),
+5 -1
View File
@@ -15,7 +15,11 @@ export default function* loginSaga() {
yield fork(services.initializeLogin);
yield take([ActionTypes.AUTHENTICATE__SUCCESS, ActionTypes.TERMS_ACCEPT__SUCCESS]);
yield take([
ActionTypes.AUTHENTICATE__SUCCESS,
ActionTypes.TOTP_VERIFY__SUCCESS,
ActionTypes.TERMS_ACCEPT__SUCCESS,
]);
yield cancel(watcherTasks);
yield call(services.goToRoot);
+39
View File
@@ -94,6 +94,43 @@ export function* updateTermsLanguage(value) {
yield put(actions.updateTermsLanguage.success(terms));
}
export function* verifyTotp(data) {
yield put(actions.verifyTotp(data));
const { pendingToken } = yield select(selectors.selectAuthenticateForm);
let accessToken;
try {
({ item: accessToken } = yield call(api.verifyTotp, {
...data,
pendingToken,
}));
} catch (error) {
yield put(actions.verifyTotp.failure(error));
return;
}
yield call(setAccessToken, accessToken);
yield put(actions.verifyTotp.success(accessToken));
}
export function* cancelTotpChallenge() {
const { pendingToken } = yield select(selectors.selectAuthenticateForm);
yield put(actions.cancelTotpChallenge());
try {
yield call(api.revokePendingToken, {
pendingToken,
});
} catch (error) {
yield put(actions.cancelTotpChallenge.failure(error));
return;
}
yield put(actions.cancelTotpChallenge.success());
}
export default {
initializeLogin,
authenticate,
@@ -101,4 +138,6 @@ export default {
acceptTerms,
cancelTerms,
updateTermsLanguage,
verifyTotp,
cancelTotpChallenge,
};
+2
View File
@@ -14,6 +14,8 @@ export default function* loginWatchers() {
services.authenticate(data),
),
takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()),
takeEvery(EntryActionTypes.TOTP_VERIFY, ({ payload: { data } }) => services.verifyTotp(data)),
takeEvery(EntryActionTypes.TOTP_CHALLENGE_CANCEL, () => services.cancelTotpChallenge()),
takeEvery(EntryActionTypes.TERMS_ACCEPT, ({ payload: { signature } }) =>
services.acceptTerms(signature),
),
+4
View File
@@ -11,6 +11,9 @@ export const selectBootstrap = ({ common: { bootstrap } }) => bootstrap;
export const selectActiveUsersLimit = (state) => selectBootstrap(state).activeUsersLimit;
export const selectUserTrustedDevicesState = ({ ui: { userTrustedDevicesState } }) =>
userTrustedDevicesState;
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
export const selectAuthenticateForm = ({ ui: { authenticateForm } }) => authenticateForm;
@@ -26,6 +29,7 @@ export default {
selectIsInitializing,
selectBootstrap,
selectActiveUsersLimit,
selectUserTrustedDevicesState,
selectAccessToken,
selectAuthenticateForm,
selectUserCreateForm,