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:
Generated
+10
@@ -58,6 +58,7 @@
|
|||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-datepicker": "^9.1.0",
|
||||||
@@ -14345,6 +14346,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"photoswipe": "^5.4.4",
|
"photoswipe": "^5.4.4",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-datepicker": "^9.1.0",
|
||||||
|
|||||||
@@ -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 {
|
export default {
|
||||||
initializeLogin,
|
initializeLogin,
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -105,4 +143,6 @@ export default {
|
|||||||
acceptTerms,
|
acceptTerms,
|
||||||
cancelTerms,
|
cancelTerms,
|
||||||
updateTermsLanguage,
|
updateTermsLanguage,
|
||||||
|
verifyTotp,
|
||||||
|
cancelTotpChallenge,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => ({
|
const deleteUser = (id) => ({
|
||||||
type: ActionTypes.USER_DELETE,
|
type: ActionTypes.USER_DELETE,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -422,6 +575,14 @@ export default {
|
|||||||
createUserApiKey,
|
createUserApiKey,
|
||||||
deleteUserApiKey,
|
deleteUserApiKey,
|
||||||
clearUserApiKeyValue,
|
clearUserApiKeyValue,
|
||||||
|
setupUserTotp,
|
||||||
|
clearUserTotpSetupValue,
|
||||||
|
enableUserTotp,
|
||||||
|
disableUserTotp,
|
||||||
|
regenerateUserTotpRecoveryCodes,
|
||||||
|
clearUserTotpRecoveryCodes,
|
||||||
|
fetchUserTrustedDevices,
|
||||||
|
deleteUserTrustedDevice,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
handleUserDelete,
|
handleUserDelete,
|
||||||
addUserToCard,
|
addUserToCard,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import http from './http';
|
|||||||
const createAccessToken = (data, headers) =>
|
const createAccessToken = (data, headers) =>
|
||||||
http.post('/access-tokens?withHttpOnlyToken=true', 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?
|
// TODO: rename?
|
||||||
const acceptTerms = (data, headers) => http.post('/access-tokens/accept-terms', data, headers);
|
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 {
|
export default {
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
|
verifyTotp,
|
||||||
acceptTerms,
|
acceptTerms,
|
||||||
revokePendingToken,
|
revokePendingToken,
|
||||||
deleteCurrentAccessToken,
|
deleteCurrentAccessToken,
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`,
|
|||||||
const createUserApiKey = (userId, headers) =>
|
const createUserApiKey = (userId, headers) =>
|
||||||
socket.post(`/users/${userId}/api-key`, undefined, 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);
|
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -49,5 +65,11 @@ export default {
|
|||||||
updateUserUsername,
|
updateUserUsername,
|
||||||
updateUserAvatar,
|
updateUserAvatar,
|
||||||
createUserApiKey,
|
createUserApiKey,
|
||||||
|
setupUserTotp,
|
||||||
|
enableUserTotp,
|
||||||
|
disableUserTotp,
|
||||||
|
regenerateUserTotpRecoveryCodes,
|
||||||
|
getUserTrustedDevices,
|
||||||
|
deleteUserTrustedDevice,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import entryActions from '../../../../entry-actions';
|
|||||||
import { useSteps } from '../../../../hooks';
|
import { useSteps } from '../../../../hooks';
|
||||||
import SelectRoleStep from './SelectRoleStep';
|
import SelectRoleStep from './SelectRoleStep';
|
||||||
import ApiKeyStep from './ApiKeyStep';
|
import ApiKeyStep from './ApiKeyStep';
|
||||||
|
import ResetTotpStep from './ResetTotpStep';
|
||||||
import ConfirmationStep from '../../ConfirmationStep';
|
import ConfirmationStep from '../../ConfirmationStep';
|
||||||
import EditUserInformationStep from '../../../users/EditUserInformationStep';
|
import EditUserInformationStep from '../../../users/EditUserInformationStep';
|
||||||
import EditUserAvatarStep from '../../../users/EditUserAvatarStep';
|
import EditUserAvatarStep from '../../../users/EditUserAvatarStep';
|
||||||
@@ -32,6 +33,7 @@ const StepTypes = {
|
|||||||
EDIT_PASSWORD: 'EDIT_PASSWORD',
|
EDIT_PASSWORD: 'EDIT_PASSWORD',
|
||||||
EDIT_ROLE: 'EDIT_ROLE',
|
EDIT_ROLE: 'EDIT_ROLE',
|
||||||
API_KEY: 'API_KEY',
|
API_KEY: 'API_KEY',
|
||||||
|
RESET_TOTP: 'RESET_TOTP',
|
||||||
ACTIVATE: 'ACTIVATE',
|
ACTIVATE: 'ACTIVATE',
|
||||||
DEACTIVATE: 'DEACTIVATE',
|
DEACTIVATE: 'DEACTIVATE',
|
||||||
DELETE: 'DELETE',
|
DELETE: 'DELETE',
|
||||||
@@ -112,6 +114,10 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
openStep(StepTypes.API_KEY);
|
openStep(StepTypes.API_KEY);
|
||||||
}, [openStep]);
|
}, [openStep]);
|
||||||
|
|
||||||
|
const handleResetTotpClick = useCallback(() => {
|
||||||
|
openStep(StepTypes.RESET_TOTP);
|
||||||
|
}, [openStep]);
|
||||||
|
|
||||||
const handleActivateClick = useCallback(() => {
|
const handleActivateClick = useCallback(() => {
|
||||||
openStep(StepTypes.ACTIVATE);
|
openStep(StepTypes.ACTIVATE);
|
||||||
}, [openStep]);
|
}, [openStep]);
|
||||||
@@ -150,6 +156,8 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
);
|
);
|
||||||
case StepTypes.API_KEY:
|
case StepTypes.API_KEY:
|
||||||
return <ApiKeyStep userId={userId} onBack={handleBack} onClose={onClose} />;
|
return <ApiKeyStep userId={userId} onBack={handleBack} onClose={onClose} />;
|
||||||
|
case StepTypes.RESET_TOTP:
|
||||||
|
return <ResetTotpStep userId={userId} onBack={handleBack} onClose={onClose} />;
|
||||||
case StepTypes.ACTIVATE:
|
case StepTypes.ACTIVATE:
|
||||||
return (
|
return (
|
||||||
<ConfirmationStep
|
<ConfirmationStep
|
||||||
@@ -246,6 +254,14 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||||||
context: 'title',
|
context: 'title',
|
||||||
})}
|
})}
|
||||||
</Menu.Item>
|
</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 && (
|
{!isCurrentUser && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
<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 { isUsername } from '../../../utils/validator';
|
||||||
import AccessTokenSteps from '../../../constants/AccessTokenSteps';
|
import AccessTokenSteps from '../../../constants/AccessTokenSteps';
|
||||||
import TermsModal from './TermsModal';
|
import TermsModal from './TermsModal';
|
||||||
|
import TotpChallengeModal from './TotpChallengeModal';
|
||||||
|
|
||||||
import logo from '../../../assets/images/logo.png';
|
import logo from '../../../assets/images/logo.png';
|
||||||
|
|
||||||
@@ -268,6 +269,7 @@ const Content = React.memo(() => {
|
|||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
{step === AccessTokenSteps.ACCEPT_TERMS && <TermsModal />}
|
{step === AccessTokenSteps.ACCEPT_TERMS && <TermsModal />}
|
||||||
|
{step === AccessTokenSteps.VERIFY_TOTP && <TotpChallengeModal />}
|
||||||
</div>
|
</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;
|
||||||
+33
@@ -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;
|
||||||
+114
@@ -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;
|
||||||
+59
@@ -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;
|
||||||
+45
@@ -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 AccountPane from './AccountPane';
|
||||||
import PreferencesPane from './PreferencesPane';
|
import PreferencesPane from './PreferencesPane';
|
||||||
import NotificationsPane from './NotificationsPane';
|
import NotificationsPane from './NotificationsPane';
|
||||||
|
import SecurityPane from './SecurityPane';
|
||||||
|
|
||||||
const UserSettingsModal = React.memo(() => {
|
const UserSettingsModal = React.memo(() => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -43,6 +44,12 @@ const UserSettingsModal = React.memo(() => {
|
|||||||
}),
|
}),
|
||||||
render: () => <NotificationsPane />,
|
render: () => <NotificationsPane />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
menuItem: t('common.security', {
|
||||||
|
context: 'title',
|
||||||
|
}),
|
||||||
|
render: () => <SecurityPane />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,4 +5,5 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
ACCEPT_TERMS: 'accept-terms',
|
ACCEPT_TERMS: 'accept-terms',
|
||||||
|
VERIFY_TOTP: 'verify-totp',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export default {
|
|||||||
AUTHENTICATE__SUCCESS: 'AUTHENTICATE__SUCCESS',
|
AUTHENTICATE__SUCCESS: 'AUTHENTICATE__SUCCESS',
|
||||||
AUTHENTICATE__FAILURE: 'AUTHENTICATE__FAILURE',
|
AUTHENTICATE__FAILURE: 'AUTHENTICATE__FAILURE',
|
||||||
AUTHENTICATE_ERROR_CLEAR: 'AUTHENTICATE_ERROR_CLEAR',
|
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: 'TERMS_ACCEPT',
|
||||||
TERMS_ACCEPT__SUCCESS: 'TERMS_ACCEPT__SUCCESS',
|
TERMS_ACCEPT__SUCCESS: 'TERMS_ACCEPT__SUCCESS',
|
||||||
TERMS_ACCEPT__FAILURE: 'TERMS_ACCEPT__FAILURE',
|
TERMS_ACCEPT__FAILURE: 'TERMS_ACCEPT__FAILURE',
|
||||||
@@ -80,6 +86,26 @@ export default {
|
|||||||
/* Users */
|
/* Users */
|
||||||
|
|
||||||
USERS_RESET_HANDLE: 'USERS_RESET_HANDLE',
|
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: 'USER_CREATE',
|
||||||
USER_CREATE__SUCCESS: 'USER_CREATE__SUCCESS',
|
USER_CREATE__SUCCESS: 'USER_CREATE__SUCCESS',
|
||||||
USER_CREATE__FAILURE: 'USER_CREATE__FAILURE',
|
USER_CREATE__FAILURE: 'USER_CREATE__FAILURE',
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export default {
|
|||||||
|
|
||||||
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
|
AUTHENTICATE: `${PREFIX}/AUTHENTICATE`,
|
||||||
AUTHENTICATE_ERROR_CLEAR: `${PREFIX}/AUTHENTICATE_ERROR_CLEAR`,
|
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_ACCEPT: `${PREFIX}/TERMS_ACCEPT`,
|
||||||
TERMS_CANCEL: `${PREFIX}/TERMS_CANCEL`,
|
TERMS_CANCEL: `${PREFIX}/TERMS_CANCEL`,
|
||||||
TERMS_LANGUAGE_UPDATE: `${PREFIX}/TERMS_LANGUAGE_UPDATE`,
|
TERMS_LANGUAGE_UPDATE: `${PREFIX}/TERMS_LANGUAGE_UPDATE`,
|
||||||
@@ -60,6 +62,15 @@ export default {
|
|||||||
USER_CREATE_ERROR_CLEAR: `${PREFIX}/USER_CREATE_ERROR_CLEAR`,
|
USER_CREATE_ERROR_CLEAR: `${PREFIX}/USER_CREATE_ERROR_CLEAR`,
|
||||||
USER_UPDATE: `${PREFIX}/USER_UPDATE`,
|
USER_UPDATE: `${PREFIX}/USER_UPDATE`,
|
||||||
CURRENT_USER_UPDATE: `${PREFIX}/CURRENT_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`,
|
USER_UPDATE_HANDLE: `${PREFIX}/USER_UPDATE_HANDLE`,
|
||||||
CURRENT_USER_LANGUAGE_UPDATE: `${PREFIX}/CURRENT_USER_LANGUAGE_UPDATE`,
|
CURRENT_USER_LANGUAGE_UPDATE: `${PREFIX}/CURRENT_USER_LANGUAGE_UPDATE`,
|
||||||
USER_EMAIL_UPDATE: `${PREFIX}/USER_EMAIL_UPDATE`,
|
USER_EMAIL_UPDATE: `${PREFIX}/USER_EMAIL_UPDATE`,
|
||||||
|
|||||||
@@ -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 {
|
export default {
|
||||||
authenticate,
|
authenticate,
|
||||||
clearAuthenticateError,
|
clearAuthenticateError,
|
||||||
acceptTerms,
|
acceptTerms,
|
||||||
cancelTerms,
|
cancelTerms,
|
||||||
updateTermsLanguage,
|
updateTermsLanguage,
|
||||||
|
verifyTotp,
|
||||||
|
cancelTotpChallenge,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => ({
|
const deleteUser = (id) => ({
|
||||||
type: EntryActionTypes.USER_DELETE,
|
type: EntryActionTypes.USER_DELETE,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -284,6 +342,15 @@ export default {
|
|||||||
createUserApiKey,
|
createUserApiKey,
|
||||||
deleteUserApiKey,
|
deleteUserApiKey,
|
||||||
clearUserApiKeyValue,
|
clearUserApiKeyValue,
|
||||||
|
setupCurrentUserTotp,
|
||||||
|
clearCurrentUserTotpSetupValue,
|
||||||
|
enableCurrentUserTotp,
|
||||||
|
disableCurrentUserTotp,
|
||||||
|
disableUserTotp,
|
||||||
|
regenerateCurrentUserTotpRecoveryCodes,
|
||||||
|
clearCurrentUserTotpRecoveryCodes,
|
||||||
|
fetchCurrentUserTrustedDevices,
|
||||||
|
deleteCurrentUserTrustedDevice,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
handleUserDelete,
|
handleUserDelete,
|
||||||
addUserToCard,
|
addUserToCard,
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export default {
|
|||||||
closed: 'Geschlossen',
|
closed: 'Geschlossen',
|
||||||
color: 'Farbe',
|
color: 'Farbe',
|
||||||
comments: 'Kommentare',
|
comments: 'Kommentare',
|
||||||
|
confirmCodesSaved: 'Ich habe diese Codes an einem sicheren Ort gespeichert.',
|
||||||
contentExceedsLimit: 'Inhalt überschreitet {{limit}}',
|
contentExceedsLimit: 'Inhalt überschreitet {{limit}}',
|
||||||
contentOfThisAttachmentIsTooBigToDisplay:
|
contentOfThisAttachmentIsTooBigToDisplay:
|
||||||
'Der Inhalt dieses Anhangs ist zu groß für die Anzeige.',
|
'Der Inhalt dieses Anhangs ist zu groß für die Anzeige.',
|
||||||
@@ -159,9 +160,52 @@ export default {
|
|||||||
customFields_title: 'Feldgruppen',
|
customFields_title: 'Feldgruppen',
|
||||||
customerPanel_title: 'Kundenpanel',
|
customerPanel_title: 'Kundenpanel',
|
||||||
dangerZone_title: 'Gefahrenbereich',
|
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: '✨ Mehr Features für eure Boards: PLANKA Pro entdecken',
|
||||||
discoverPlankaPro_title: 'PLANKA Pro entdecken',
|
discoverPlankaPro_title: 'PLANKA Pro entdecken',
|
||||||
dismissProBannerFor30Days: 'Für 30 Tage schließen',
|
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',
|
upgradeTeamToPro_title: 'Team auf Pro upgraden',
|
||||||
proFeatureCalendar: '✨ Kalenderansicht für eure Boards',
|
proFeatureCalendar: '✨ Kalenderansicht für eure Boards',
|
||||||
proFeatureRecurringCards: '✨ Wiederkehrende Karten',
|
proFeatureRecurringCards: '✨ Wiederkehrende Karten',
|
||||||
@@ -422,8 +466,10 @@ export default {
|
|||||||
archiveCards: 'Karten archivieren',
|
archiveCards: 'Karten archivieren',
|
||||||
archiveCards_title: 'Karten archivieren',
|
archiveCards_title: 'Karten archivieren',
|
||||||
assignAsOwner: 'Als Eigentümer zuweisen',
|
assignAsOwner: 'Als Eigentümer zuweisen',
|
||||||
|
back: 'Zurück',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
copy: 'Kopieren',
|
copy: 'Kopieren',
|
||||||
|
copyAll: 'Alle kopieren',
|
||||||
copyCard_title: 'Karte Kopieren',
|
copyCard_title: 'Karte Kopieren',
|
||||||
createApiKey: 'API-Schlüssel erstellen',
|
createApiKey: 'API-Schlüssel erstellen',
|
||||||
createBoard: 'Arbeitsbereich erstellen',
|
createBoard: 'Arbeitsbereich erstellen',
|
||||||
@@ -463,7 +509,9 @@ export default {
|
|||||||
deleteUser: 'Benutzer löschen',
|
deleteUser: 'Benutzer löschen',
|
||||||
deleteUser_title: 'Benutzer löschen',
|
deleteUser_title: 'Benutzer löschen',
|
||||||
deleteWebhook: 'Webhook löschen',
|
deleteWebhook: 'Webhook löschen',
|
||||||
|
disable2fa: '2FA deaktivieren',
|
||||||
dismissAll: 'Alle verwerfen',
|
dismissAll: 'Alle verwerfen',
|
||||||
|
done: 'Fertig',
|
||||||
download: 'Herunterladen',
|
download: 'Herunterladen',
|
||||||
duplicateCard_title: 'Karte duplizieren',
|
duplicateCard_title: 'Karte duplizieren',
|
||||||
edit: 'Bearbeiten',
|
edit: 'Bearbeiten',
|
||||||
@@ -483,6 +531,7 @@ export default {
|
|||||||
editUsername_title: 'Benutzername ändern',
|
editUsername_title: 'Benutzername ändern',
|
||||||
emptyTrash: 'Papierkorb leeren',
|
emptyTrash: 'Papierkorb leeren',
|
||||||
emptyTrash_title: 'Papierkorb leeren',
|
emptyTrash_title: 'Papierkorb leeren',
|
||||||
|
enable2fa: '2FA aktivieren',
|
||||||
import: 'Import',
|
import: 'Import',
|
||||||
join: 'Beitreten',
|
join: 'Beitreten',
|
||||||
leave: 'Verlassen',
|
leave: 'Verlassen',
|
||||||
@@ -497,7 +546,9 @@ export default {
|
|||||||
move: 'Verschieben',
|
move: 'Verschieben',
|
||||||
moveCard_title: 'Karte bewegen',
|
moveCard_title: 'Karte bewegen',
|
||||||
moveList_title: 'Liste verschieben',
|
moveList_title: 'Liste verschieben',
|
||||||
|
regenerate: 'Neu generieren',
|
||||||
regenerateApiKey: 'API-Schlüssel neu generieren',
|
regenerateApiKey: 'API-Schlüssel neu generieren',
|
||||||
|
regenerateRecoveryCodes: 'Wiederherstellungscodes neu generieren',
|
||||||
remove: 'Löschen',
|
remove: 'Löschen',
|
||||||
removeAssignee: 'Zuständigen entfernen',
|
removeAssignee: 'Zuständigen entfernen',
|
||||||
removeColor: 'Farbe löschen',
|
removeColor: 'Farbe löschen',
|
||||||
@@ -506,8 +557,10 @@ export default {
|
|||||||
removeFromProject: 'Vom Projekt entfernen',
|
removeFromProject: 'Vom Projekt entfernen',
|
||||||
removeManager: 'Projektleiter entfernen',
|
removeManager: 'Projektleiter entfernen',
|
||||||
removeMember: 'Mitglied entfernen',
|
removeMember: 'Mitglied entfernen',
|
||||||
|
reset2fa: '2FA zurücksetzen',
|
||||||
restoreToList: 'Wiederherstellen in {{list}}',
|
restoreToList: 'Wiederherstellen in {{list}}',
|
||||||
returnToBoard: 'Zurück zum Arbeitsbereich',
|
returnToBoard: 'Zurück zum Arbeitsbereich',
|
||||||
|
revoke: 'Widerrufen',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
sendTestEmail: 'Test-E-Mail senden',
|
sendTestEmail: 'Test-E-Mail senden',
|
||||||
showActive: 'Aktive anzeigen',
|
showActive: 'Aktive anzeigen',
|
||||||
@@ -524,6 +577,7 @@ export default {
|
|||||||
unsubscribe: 'De-abonnieren',
|
unsubscribe: 'De-abonnieren',
|
||||||
uploadNewAvatar: 'Neuen Avatar hochladen',
|
uploadNewAvatar: 'Neuen Avatar hochladen',
|
||||||
uploadNewImage: 'Neues Bild hochladen',
|
uploadNewImage: 'Neues Bild hochladen',
|
||||||
|
verify: 'Bestätigen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ export default {
|
|||||||
'Admin-Anmeldung erforderlich zur Initialisierung der Instanz',
|
'Admin-Anmeldung erforderlich zur Initialisierung der Instanz',
|
||||||
emailAlreadyInUse: 'E-mail Adresse wird bereits benutzt',
|
emailAlreadyInUse: 'E-mail Adresse wird bereits benutzt',
|
||||||
emailOrUsername: 'E-Mail-Adresse oder Benutzername',
|
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',
|
invalidCredentials: 'Ungültige Anmeldeinformationen',
|
||||||
invalidEmailOrUsername: 'Ungültige E-Mail-Adresse oder Benutzername',
|
invalidEmailOrUsername: 'Ungültige E-Mail-Adresse oder Benutzername',
|
||||||
invalidPassword: 'Ungültiges Passwort',
|
invalidPassword: 'Ungültiges Passwort',
|
||||||
|
invalidTotpCode: 'Ungültiger TOTP- oder Wiederherstellungscode',
|
||||||
logIn_title: 'Anmelden',
|
logIn_title: 'Anmelden',
|
||||||
noInternetConnection: 'Keine Internetverbindung',
|
noInternetConnection: 'Keine Internetverbindung',
|
||||||
or: 'Oder',
|
or: 'Oder',
|
||||||
@@ -16,6 +19,9 @@ export default {
|
|||||||
password: 'Passwort',
|
password: 'Passwort',
|
||||||
poweredByPlanka: 'Powered by <1>PLANKA</1>',
|
poweredByPlanka: 'Powered by <1>PLANKA</1>',
|
||||||
serverConnectionFailed: 'Serververbindung fehlgeschlagen',
|
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',
|
unknownError: 'Unbekannter Fehler, bitte später erneut versuchen',
|
||||||
usernameAlreadyInUse: 'Benutzername wird bereits verwendet',
|
usernameAlreadyInUse: 'Benutzername wird bereits verwendet',
|
||||||
whoops_title: 'Hoppla!',
|
whoops_title: 'Hoppla!',
|
||||||
@@ -27,6 +33,7 @@ export default {
|
|||||||
goBack: 'Zurück gehen',
|
goBack: 'Zurück gehen',
|
||||||
goHome: 'Zur Startseite',
|
goHome: 'Zur Startseite',
|
||||||
logIn: 'Einloggen',
|
logIn: 'Einloggen',
|
||||||
|
verify: 'Bestätigen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export default {
|
|||||||
closed: 'Closed',
|
closed: 'Closed',
|
||||||
color: 'Color',
|
color: 'Color',
|
||||||
comments: 'Comments',
|
comments: 'Comments',
|
||||||
|
confirmCodesSaved: 'I have saved these codes in a safe place.',
|
||||||
contentExceedsLimit: 'Content exceeds {{limit}}',
|
contentExceedsLimit: 'Content exceeds {{limit}}',
|
||||||
contentOfThisAttachmentIsTooBigToDisplay: 'Content of this attachment is too big to display.',
|
contentOfThisAttachmentIsTooBigToDisplay: 'Content of this attachment is too big to display.',
|
||||||
copy_inline: 'copy',
|
copy_inline: 'copy',
|
||||||
@@ -138,9 +139,51 @@ export default {
|
|||||||
customFields_title: 'Custom Fields',
|
customFields_title: 'Custom Fields',
|
||||||
customerPanel_title: 'Customer Panel',
|
customerPanel_title: 'Customer Panel',
|
||||||
dangerZone_title: 'Danger Zone',
|
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: '✨ More features for your boards: Discover PLANKA Pro',
|
||||||
discoverPlankaPro_title: 'Discover PLANKA Pro',
|
discoverPlankaPro_title: 'Discover PLANKA Pro',
|
||||||
dismissProBannerFor30Days: 'Dismiss for 30 days',
|
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',
|
upgradeTeamToPro_title: 'Upgrade Team to Pro',
|
||||||
proFeatureCalendar: '✨ Calendar View for your boards',
|
proFeatureCalendar: '✨ Calendar View for your boards',
|
||||||
proFeatureRecurringCards: '✨ Recurring Cards',
|
proFeatureRecurringCards: '✨ Recurring Cards',
|
||||||
@@ -397,8 +440,10 @@ export default {
|
|||||||
archiveCards: 'Archive cards',
|
archiveCards: 'Archive cards',
|
||||||
archiveCards_title: 'Archive Cards',
|
archiveCards_title: 'Archive Cards',
|
||||||
assignAsOwner: 'Assign as owner',
|
assignAsOwner: 'Assign as owner',
|
||||||
|
back: 'Back',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
|
copyAll: 'Copy all',
|
||||||
copyCard_title: 'Copy Card',
|
copyCard_title: 'Copy Card',
|
||||||
createApiKey: 'Create API key',
|
createApiKey: 'Create API key',
|
||||||
createBoard: 'Create board',
|
createBoard: 'Create board',
|
||||||
@@ -438,7 +483,9 @@ export default {
|
|||||||
deleteUser: 'Delete user',
|
deleteUser: 'Delete user',
|
||||||
deleteUser_title: 'Delete User',
|
deleteUser_title: 'Delete User',
|
||||||
deleteWebhook: 'Delete webhook',
|
deleteWebhook: 'Delete webhook',
|
||||||
|
disable2fa: 'Disable 2FA',
|
||||||
dismissAll: 'Dismiss all',
|
dismissAll: 'Dismiss all',
|
||||||
|
done: 'Done',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
duplicateCard_title: 'Duplicate Card',
|
duplicateCard_title: 'Duplicate Card',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
@@ -458,6 +505,7 @@ export default {
|
|||||||
editUsername_title: 'Edit Username',
|
editUsername_title: 'Edit Username',
|
||||||
emptyTrash: 'Empty trash',
|
emptyTrash: 'Empty trash',
|
||||||
emptyTrash_title: 'Empty Trash',
|
emptyTrash_title: 'Empty Trash',
|
||||||
|
enable2fa: 'Enable 2FA',
|
||||||
import: 'Import',
|
import: 'Import',
|
||||||
join: 'Join',
|
join: 'Join',
|
||||||
leave: 'Leave',
|
leave: 'Leave',
|
||||||
@@ -472,7 +520,9 @@ export default {
|
|||||||
move: 'Move',
|
move: 'Move',
|
||||||
moveCard_title: 'Move Card',
|
moveCard_title: 'Move Card',
|
||||||
moveList_title: 'Move List',
|
moveList_title: 'Move List',
|
||||||
|
regenerate: 'Regenerate',
|
||||||
regenerateApiKey: 'Regenerate API key',
|
regenerateApiKey: 'Regenerate API key',
|
||||||
|
regenerateRecoveryCodes: 'Regenerate recovery codes',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
removeAssignee: 'Remove assignee',
|
removeAssignee: 'Remove assignee',
|
||||||
removeColor: 'Remove color',
|
removeColor: 'Remove color',
|
||||||
@@ -481,8 +531,10 @@ export default {
|
|||||||
removeFromProject: 'Remove from project',
|
removeFromProject: 'Remove from project',
|
||||||
removeManager: 'Remove manager',
|
removeManager: 'Remove manager',
|
||||||
removeMember: 'Remove member',
|
removeMember: 'Remove member',
|
||||||
|
reset2fa: 'Reset 2FA',
|
||||||
restoreToList: 'Restore to {{list}}',
|
restoreToList: 'Restore to {{list}}',
|
||||||
returnToBoard: 'Return to board',
|
returnToBoard: 'Return to board',
|
||||||
|
revoke: 'Revoke',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
sendTestEmail: 'Send test email',
|
sendTestEmail: 'Send test email',
|
||||||
showActive: 'Show active',
|
showActive: 'Show active',
|
||||||
@@ -499,6 +551,7 @@ export default {
|
|||||||
unsubscribe: 'Unsubscribe',
|
unsubscribe: 'Unsubscribe',
|
||||||
uploadNewAvatar: 'Upload new avatar',
|
uploadNewAvatar: 'Upload new avatar',
|
||||||
uploadNewImage: 'Upload new image',
|
uploadNewImage: 'Upload new image',
|
||||||
|
verify: 'Verify',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ export default {
|
|||||||
adminLoginRequiredToInitializeInstance: 'Admin login required to initialize instance',
|
adminLoginRequiredToInitializeInstance: 'Admin login required to initialize instance',
|
||||||
emailAlreadyInUse: 'E-mail already in use',
|
emailAlreadyInUse: 'E-mail already in use',
|
||||||
emailOrUsername: 'E-mail or username',
|
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',
|
invalidCredentials: 'Invalid credentials',
|
||||||
invalidEmailOrUsername: 'Invalid e-mail or username',
|
invalidEmailOrUsername: 'Invalid e-mail or username',
|
||||||
invalidPassword: 'Invalid password',
|
invalidPassword: 'Invalid password',
|
||||||
|
invalidTotpCode: 'Invalid TOTP or recovery code',
|
||||||
logIn_title: 'Log In',
|
logIn_title: 'Log In',
|
||||||
noInternetConnection: 'No internet connection',
|
noInternetConnection: 'No internet connection',
|
||||||
or: 'Or',
|
or: 'Or',
|
||||||
@@ -15,6 +18,9 @@ export default {
|
|||||||
password: 'Password',
|
password: 'Password',
|
||||||
poweredByPlanka: 'Powered by <1>PLANKA</1>',
|
poweredByPlanka: 'Powered by <1>PLANKA</1>',
|
||||||
serverConnectionFailed: 'Server connection failed',
|
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',
|
unknownError: 'Unknown error, try again later',
|
||||||
usernameAlreadyInUse: 'Username already in use',
|
usernameAlreadyInUse: 'Username already in use',
|
||||||
whoops_title: 'Whoops!',
|
whoops_title: 'Whoops!',
|
||||||
@@ -26,6 +32,7 @@ export default {
|
|||||||
goBack: 'Go back',
|
goBack: 'Go back',
|
||||||
goHome: 'Go home',
|
goHome: 'Go home',
|
||||||
logIn: 'Log in',
|
logIn: 'Log in',
|
||||||
|
verify: 'Verify',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ const filterProjectModels = (projectModels, search, isHidden) => {
|
|||||||
return filteredProjectModels;
|
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 {
|
export default class extends BaseModel {
|
||||||
static modelName = 'User';
|
static modelName = 'User';
|
||||||
|
|
||||||
@@ -78,11 +89,17 @@ export default class extends BaseModel {
|
|||||||
subscribeToCardWhenCommenting: attr(),
|
subscribeToCardWhenCommenting: attr(),
|
||||||
turnOffRecentCardHighlighting: attr(),
|
turnOffRecentCardHighlighting: attr(),
|
||||||
isDefaultAdmin: attr(),
|
isDefaultAdmin: attr(),
|
||||||
|
isTotpEnabled: attr(),
|
||||||
|
totpEnabledAt: attr(),
|
||||||
|
totpRecoveryCodesRemaining: attr(),
|
||||||
isDeactivated: attr(),
|
isDeactivated: attr(),
|
||||||
lockedFieldNames: attr(),
|
lockedFieldNames: attr(),
|
||||||
isAvatarUpdating: attr({
|
isAvatarUpdating: attr({
|
||||||
getDefault: () => false,
|
getDefault: () => false,
|
||||||
}),
|
}),
|
||||||
|
totpState: attr({
|
||||||
|
getDefault: () => DEFAULT_TOTP_STATE,
|
||||||
|
}),
|
||||||
emailUpdateForm: attr({
|
emailUpdateForm: attr({
|
||||||
getDefault: () => DEFAULT_EMAIL_UPDATE_FORM,
|
getDefault: () => DEFAULT_EMAIL_UPDATE_FORM,
|
||||||
}),
|
}),
|
||||||
@@ -363,6 +380,160 @@ export default class extends BaseModel {
|
|||||||
|
|
||||||
break;
|
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:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const initialState = {
|
|||||||
export default (state = initialState, { type, payload }) => {
|
export default (state = initialState, { type, payload }) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ActionTypes.AUTHENTICATE__SUCCESS:
|
case ActionTypes.AUTHENTICATE__SUCCESS:
|
||||||
|
case ActionTypes.TOTP_VERIFY__SUCCESS:
|
||||||
case ActionTypes.TERMS_ACCEPT__SUCCESS:
|
case ActionTypes.TERMS_ACCEPT__SUCCESS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default (state = initialState, { type, payload }) => {
|
|||||||
bootstrap: payload.bootstrap,
|
bootstrap: payload.bootstrap,
|
||||||
};
|
};
|
||||||
case ActionTypes.AUTHENTICATE__SUCCESS:
|
case ActionTypes.AUTHENTICATE__SUCCESS:
|
||||||
|
case ActionTypes.TOTP_VERIFY__SUCCESS:
|
||||||
case ActionTypes.TERMS_ACCEPT__SUCCESS:
|
case ActionTypes.TERMS_ACCEPT__SUCCESS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import ActionTypes from '../../constants/ActionTypes';
|
import ActionTypes from '../../constants/ActionTypes';
|
||||||
|
import AccessTokenSteps from '../../constants/AccessTokenSteps';
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
data: {
|
data: {
|
||||||
@@ -20,6 +21,11 @@ const initialState = {
|
|||||||
isCancelling: false,
|
isCancelling: false,
|
||||||
isLanguageUpdating: false,
|
isLanguageUpdating: false,
|
||||||
},
|
},
|
||||||
|
totpForm: {
|
||||||
|
isSubmitting: false,
|
||||||
|
isCancelling: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line default-param-last
|
// 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_ACCEPT__SUCCESS:
|
||||||
case ActionTypes.TERMS_CANCEL__SUCCESS:
|
case ActionTypes.TERMS_CANCEL__SUCCESS:
|
||||||
case ActionTypes.TERMS_CANCEL__FAILURE:
|
case ActionTypes.TERMS_CANCEL__FAILURE:
|
||||||
|
case ActionTypes.TOTP_VERIFY__SUCCESS:
|
||||||
|
case ActionTypes.TOTP_CHALLENGE_CANCEL__SUCCESS:
|
||||||
|
case ActionTypes.TOTP_CHALLENGE_CANCEL__FAILURE:
|
||||||
return initialState;
|
return initialState;
|
||||||
case ActionTypes.AUTHENTICATE__FAILURE:
|
case ActionTypes.AUTHENTICATE__FAILURE:
|
||||||
if (payload.terms) {
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
error: payload.error,
|
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:
|
case ActionTypes.AUTHENTICATE_ERROR_CLEAR:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import authenticateForm from './authenticate-form';
|
|||||||
import userCreateForm from './user-create-form';
|
import userCreateForm from './user-create-form';
|
||||||
import projectCreateForm from './project-create-form';
|
import projectCreateForm from './project-create-form';
|
||||||
import smtpTestState from './smtp-test-state';
|
import smtpTestState from './smtp-test-state';
|
||||||
|
import userTrustedDevicesState from './user-trusted-devices-state';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
authenticateForm,
|
authenticateForm,
|
||||||
userCreateForm,
|
userCreateForm,
|
||||||
projectCreateForm,
|
projectCreateForm,
|
||||||
smtpTestState,
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -388,6 +388,136 @@ export function* clearUserApiKeyValue(id) {
|
|||||||
yield put(actions.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) {
|
export function* deleteUser(id) {
|
||||||
yield put(actions.deleteUser(id));
|
yield put(actions.deleteUser(id));
|
||||||
|
|
||||||
@@ -528,6 +658,20 @@ export default {
|
|||||||
createUserApiKey,
|
createUserApiKey,
|
||||||
deleteUserApiKey,
|
deleteUserApiKey,
|
||||||
clearUserApiKeyValue,
|
clearUserApiKeyValue,
|
||||||
|
setupUserTotp,
|
||||||
|
setupCurrentUserTotp,
|
||||||
|
clearCurrentUserTotpSetupValue,
|
||||||
|
enableUserTotp,
|
||||||
|
enableCurrentUserTotp,
|
||||||
|
disableUserTotp,
|
||||||
|
disableCurrentUserTotp,
|
||||||
|
regenerateUserTotpRecoveryCodes,
|
||||||
|
regenerateCurrentUserTotpRecoveryCodes,
|
||||||
|
clearCurrentUserTotpRecoveryCodes,
|
||||||
|
fetchUserTrustedDevices,
|
||||||
|
fetchCurrentUserTrustedDevices,
|
||||||
|
deleteUserTrustedDevice,
|
||||||
|
deleteCurrentUserTrustedDevice,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
handleUserDelete,
|
handleUserDelete,
|
||||||
addUserToCard,
|
addUserToCard,
|
||||||
|
|||||||
@@ -79,6 +79,34 @@ export default function* usersWatchers() {
|
|||||||
takeEvery(EntryActionTypes.USER_API_KEY_VALUE_CLEAR, ({ payload: { id } }) =>
|
takeEvery(EntryActionTypes.USER_API_KEY_VALUE_CLEAR, ({ payload: { id } }) =>
|
||||||
services.clearUserApiKeyValue(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, ({ payload: { id } }) => services.deleteUser(id)),
|
||||||
takeEvery(EntryActionTypes.USER_DELETE_HANDLE, ({ payload: { user } }) =>
|
takeEvery(EntryActionTypes.USER_DELETE_HANDLE, ({ payload: { user } }) =>
|
||||||
services.handleUserDelete(user),
|
services.handleUserDelete(user),
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export default function* loginSaga() {
|
|||||||
|
|
||||||
yield fork(services.initializeLogin);
|
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 cancel(watcherTasks);
|
||||||
yield call(services.goToRoot);
|
yield call(services.goToRoot);
|
||||||
|
|||||||
@@ -94,6 +94,43 @@ export function* updateTermsLanguage(value) {
|
|||||||
yield put(actions.updateTermsLanguage.success(terms));
|
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 {
|
export default {
|
||||||
initializeLogin,
|
initializeLogin,
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -101,4 +138,6 @@ export default {
|
|||||||
acceptTerms,
|
acceptTerms,
|
||||||
cancelTerms,
|
cancelTerms,
|
||||||
updateTermsLanguage,
|
updateTermsLanguage,
|
||||||
|
verifyTotp,
|
||||||
|
cancelTotpChallenge,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default function* loginWatchers() {
|
|||||||
services.authenticate(data),
|
services.authenticate(data),
|
||||||
),
|
),
|
||||||
takeEvery(EntryActionTypes.AUTHENTICATE_ERROR_CLEAR, () => services.clearAuthenticateError()),
|
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 } }) =>
|
takeEvery(EntryActionTypes.TERMS_ACCEPT, ({ payload: { signature } }) =>
|
||||||
services.acceptTerms(signature),
|
services.acceptTerms(signature),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const selectBootstrap = ({ common: { bootstrap } }) => bootstrap;
|
|||||||
|
|
||||||
export const selectActiveUsersLimit = (state) => selectBootstrap(state).activeUsersLimit;
|
export const selectActiveUsersLimit = (state) => selectBootstrap(state).activeUsersLimit;
|
||||||
|
|
||||||
|
export const selectUserTrustedDevicesState = ({ ui: { userTrustedDevicesState } }) =>
|
||||||
|
userTrustedDevicesState;
|
||||||
|
|
||||||
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
|
export const selectAccessToken = ({ auth: { accessToken } }) => accessToken;
|
||||||
|
|
||||||
export const selectAuthenticateForm = ({ ui: { authenticateForm } }) => authenticateForm;
|
export const selectAuthenticateForm = ({ ui: { authenticateForm } }) => authenticateForm;
|
||||||
@@ -26,6 +29,7 @@ export default {
|
|||||||
selectIsInitializing,
|
selectIsInitializing,
|
||||||
selectBootstrap,
|
selectBootstrap,
|
||||||
selectActiveUsersLimit,
|
selectActiveUsersLimit,
|
||||||
|
selectUserTrustedDevicesState,
|
||||||
selectAccessToken,
|
selectAccessToken,
|
||||||
selectAuthenticateForm,
|
selectAuthenticateForm,
|
||||||
selectUserCreateForm,
|
selectUserCreateForm,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
* type: string
|
* type: string
|
||||||
* enum:
|
* enum:
|
||||||
* - Terms acceptance required
|
* - Terms acceptance required
|
||||||
|
* - TOTP verification required
|
||||||
* - Admin login required to initialize instance
|
* - Admin login required to initialize instance
|
||||||
* description: Specific error message
|
* description: Specific error message
|
||||||
* example: Terms acceptance required
|
* example: Terms acceptance required
|
||||||
@@ -155,6 +156,9 @@ module.exports = {
|
|||||||
termsAcceptanceRequired: {
|
termsAcceptanceRequired: {
|
||||||
responseType: 'forbidden',
|
responseType: 'forbidden',
|
||||||
},
|
},
|
||||||
|
totpVerificationRequired: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
adminLoginRequiredToInitializeInstance: {
|
adminLoginRequiredToInitializeInstance: {
|
||||||
responseType: 'forbidden',
|
responseType: 'forbidden',
|
||||||
},
|
},
|
||||||
@@ -197,6 +201,9 @@ module.exports = {
|
|||||||
}))
|
}))
|
||||||
.intercept('termsAcceptanceRequired', (error) => ({
|
.intercept('termsAcceptanceRequired', (error) => ({
|
||||||
termsAcceptanceRequired: error.raw,
|
termsAcceptanceRequired: error.raw,
|
||||||
|
}))
|
||||||
|
.intercept('totpVerificationRequired', (error) => ({
|
||||||
|
totpVerificationRequired: error.raw,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /access-tokens/verify-totp:
|
||||||
|
* post:
|
||||||
|
* summary: Complete TOTP step of the login flow
|
||||||
|
* description: Exchanges a pending token plus a valid TOTP or recovery code for a full access token. Optionally remembers the browser for 30 days via a trust cookie.
|
||||||
|
* tags:
|
||||||
|
* - Access Tokens
|
||||||
|
* operationId: verifyTotp
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - pendingToken
|
||||||
|
* - code
|
||||||
|
* properties:
|
||||||
|
* pendingToken:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 1024
|
||||||
|
* code:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 16
|
||||||
|
* trustDevice:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: TOTP verified, access token returned
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - item
|
||||||
|
* properties:
|
||||||
|
* item:
|
||||||
|
* type: string
|
||||||
|
* security: []
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const { getRemoteAddress } = require('../../../utils/remote-address');
|
||||||
|
|
||||||
|
const {
|
||||||
|
AccessTokenSteps,
|
||||||
|
TRUST_DEVICE_COOKIE_NAME,
|
||||||
|
TRUST_DEVICE_EXPIRES_IN_DAYS,
|
||||||
|
} = require('../../../constants');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
INVALID_PENDING_TOKEN: {
|
||||||
|
invalidPendingToken: 'Invalid pending token',
|
||||||
|
},
|
||||||
|
INVALID_TOTP_CODE: {
|
||||||
|
invalidTotpCode: 'Invalid TOTP code',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTrustDeviceCookie = (response, plainToken) => {
|
||||||
|
response.cookie(TRUST_DEVICE_COOKIE_NAME, plainToken, {
|
||||||
|
maxAge: TRUST_DEVICE_EXPIRES_IN_DAYS * 24 * 60 * 60 * 1000,
|
||||||
|
path: sails.config.custom.baseUrlPath || '/',
|
||||||
|
secure: sails.config.custom.baseUrlSecure,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
pendingToken: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 1024,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 16,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
trustDevice: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
invalidPendingToken: {
|
||||||
|
responseType: 'unauthorized',
|
||||||
|
},
|
||||||
|
invalidTotpCode: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const remoteAddress = getRemoteAddress(this.req);
|
||||||
|
const { httpOnlyToken } = this.req.cookies;
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = sails.helpers.utils.verifyJwtToken(inputs.pendingToken);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.raw && error.raw.name === 'TokenExpiredError') {
|
||||||
|
throw Errors.INVALID_PENDING_TOKEN;
|
||||||
|
}
|
||||||
|
sails.log.warn(`Invalid pending token! (IP: ${remoteAddress})`);
|
||||||
|
throw Errors.INVALID_PENDING_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.subject !== AccessTokenSteps.VERIFY_TOTP) {
|
||||||
|
throw Errors.INVALID_PENDING_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = await Session.qm.getOneUndeletedByPendingToken(inputs.pendingToken);
|
||||||
|
if (!session) {
|
||||||
|
sails.log.warn(`Invalid pending token! (IP: ${remoteAddress})`);
|
||||||
|
throw Errors.INVALID_PENDING_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.httpOnlyToken && httpOnlyToken !== session.httpOnlyToken) {
|
||||||
|
throw Errors.INVALID_PENDING_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.qm.getOneById(session.userId, {
|
||||||
|
withDeactivated: false,
|
||||||
|
});
|
||||||
|
if (!user || !user.isTotpEnabled || !user.totpSecret) {
|
||||||
|
throw Errors.INVALID_PENDING_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
let codeAccepted = sails.helpers.utils.verifyTotpCode.with({
|
||||||
|
code: inputs.code,
|
||||||
|
secret: user.totpSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
let consumedRecoveryIndex = -1;
|
||||||
|
if (!codeAccepted) {
|
||||||
|
const recoveryCodes = user.totpRecoveryCodes || [];
|
||||||
|
for (let i = 0; i < recoveryCodes.length; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
if (await bcrypt.compare(inputs.code, recoveryCodes[i])) {
|
||||||
|
codeAccepted = true;
|
||||||
|
consumedRecoveryIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!codeAccepted) {
|
||||||
|
sails.log.warn(`Invalid TOTP code! (IP: ${remoteAddress})`);
|
||||||
|
throw Errors.INVALID_TOTP_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consumedRecoveryIndex >= 0) {
|
||||||
|
const previousRecoveryCodes = user.totpRecoveryCodes || [];
|
||||||
|
const remaining = previousRecoveryCodes.filter(
|
||||||
|
(_value, idx) => idx !== consumedRecoveryIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compare-and-set: only swap the array if it still matches the state we read.
|
||||||
|
// A second concurrent verify-totp using the same recovery code will see
|
||||||
|
// rowCount = 0 here and be rejected as INVALID_TOTP_CODE — preventing replay.
|
||||||
|
const queryResult = await sails.sendNativeQuery(
|
||||||
|
'UPDATE user_account SET totp_recovery_codes = $1::jsonb, updated_at = $2 WHERE id = $3 AND totp_recovery_codes = $4::jsonb',
|
||||||
|
[
|
||||||
|
JSON.stringify(remaining),
|
||||||
|
new Date().toISOString(),
|
||||||
|
user.id,
|
||||||
|
JSON.stringify(previousRecoveryCodes),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (queryResult.rowCount === 0) {
|
||||||
|
sails.log.warn(`Recovery code race detected, rejecting (IP: ${remoteAddress})`);
|
||||||
|
throw Errors.INVALID_TOTP_CODE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
session = await Session.qm.updateOne(session.id, {
|
||||||
|
accessToken,
|
||||||
|
pendingToken: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.httpOnlyToken && !this.req.isSocket) {
|
||||||
|
sails.helpers.utils.setHttpOnlyTokenCookie(
|
||||||
|
session.httpOnlyToken,
|
||||||
|
accessTokenPayload,
|
||||||
|
this.res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs.trustDevice && !this.req.isSocket) {
|
||||||
|
const { plainToken } = await sails.helpers.trustedDevices.createOne.with({
|
||||||
|
userId: user.id,
|
||||||
|
userAgent: this.req.headers['user-agent'] || null,
|
||||||
|
});
|
||||||
|
setTrustDeviceCookie(this.res, plainToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: accessToken,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /users/{id}/trusted-devices/{deviceId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Revoke a specific trusted device
|
||||||
|
* description: Deletes one trusted-device row, forcing TOTP again on that browser at next login. Only accessible for the user themselves.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* operationId: deleteUserTrustedDevice
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: deviceId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Trusted device revoked
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/Unauthorized'
|
||||||
|
* 403:
|
||||||
|
* $ref: '#/components/responses/Forbidden'
|
||||||
|
* 404:
|
||||||
|
* $ref: '#/components/responses/NotFound'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { idInput } = require('../../../utils/inputs');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
NOT_ENOUGH_RIGHTS: {
|
||||||
|
notEnoughRights: 'Not enough rights',
|
||||||
|
},
|
||||||
|
TRUSTED_DEVICE_NOT_FOUND: {
|
||||||
|
trustedDeviceNotFound: 'Trusted device not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
id: {
|
||||||
|
...idInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
deviceId: {
|
||||||
|
...idInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
notEnoughRights: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
trustedDeviceNotFound: {
|
||||||
|
responseType: 'notFound',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const { currentUser } = this.req;
|
||||||
|
|
||||||
|
if (inputs.id !== currentUser.id) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await TrustedDevice.qm.deleteOneByUserIdAndId(currentUser.id, inputs.deviceId);
|
||||||
|
if (!deleted) {
|
||||||
|
throw Errors.TRUSTED_DEVICE_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: sails.helpers.trustedDevices.presentOne(deleted),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /users/{id}/totp:
|
||||||
|
* delete:
|
||||||
|
* summary: Disable / reset TOTP
|
||||||
|
* description: Users disable their own TOTP by providing their current password and a valid TOTP or recovery code. Admins resetting another user's TOTP must re-enter their own password (step-up auth); this invalidates all of that user's sessions and trust cookies.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* operationId: disableUserTotp
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: false
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* currentPassword:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 256
|
||||||
|
* code:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 16
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: TOTP disabled
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* item:
|
||||||
|
* $ref: '#/components/schemas/User'
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/Unauthorized'
|
||||||
|
* 403:
|
||||||
|
* $ref: '#/components/responses/Forbidden'
|
||||||
|
* 404:
|
||||||
|
* $ref: '#/components/responses/NotFound'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const { idInput } = require('../../../utils/inputs');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
NOT_ENOUGH_RIGHTS: {
|
||||||
|
notEnoughRights: 'Not enough rights',
|
||||||
|
},
|
||||||
|
INVALID_CURRENT_PASSWORD: {
|
||||||
|
invalidCurrentPassword: 'Invalid current password',
|
||||||
|
},
|
||||||
|
INVALID_TOTP_CODE: {
|
||||||
|
invalidTotpCode: 'Invalid TOTP code',
|
||||||
|
},
|
||||||
|
USER_NOT_FOUND: {
|
||||||
|
userNotFound: 'User not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOwnerCredentials = async (user, inputs) => {
|
||||||
|
if (!inputs.currentPassword) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inputs.code) {
|
||||||
|
throw Errors.INVALID_TOTP_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.totpSecret) {
|
||||||
|
const isCodeValid = sails.helpers.utils.verifyTotpCode.with({
|
||||||
|
code: inputs.code,
|
||||||
|
secret: user.totpSecret,
|
||||||
|
});
|
||||||
|
if (isCodeValid) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveryCodes = user.totpRecoveryCodes || [];
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const hashed of recoveryCodes) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
if (await bcrypt.compare(inputs.code, hashed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Errors.INVALID_TOTP_CODE;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
id: {
|
||||||
|
...idInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currentPassword: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 256,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
notEnoughRights: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
invalidCurrentPassword: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
invalidTotpCode: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
userNotFound: {
|
||||||
|
responseType: 'notFound',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const { currentSession, currentUser } = this.req;
|
||||||
|
|
||||||
|
const isSelf = inputs.id === currentUser.id;
|
||||||
|
const isAdmin = currentUser.role === User.Roles.ADMIN;
|
||||||
|
|
||||||
|
if (!isSelf && !isAdmin) {
|
||||||
|
throw Errors.USER_NOT_FOUND; // Forbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.qm.getOneById(inputs.id);
|
||||||
|
if (!user) {
|
||||||
|
throw Errors.USER_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.demoMode) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelf) {
|
||||||
|
await verifyOwnerCredentials(user, inputs);
|
||||||
|
} else {
|
||||||
|
// Admin path: step-up by re-entering the admin's own password.
|
||||||
|
// Prevents a hijacked admin session from silently stripping 2FA off other users.
|
||||||
|
if (!inputs.currentPassword) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
if (!currentUser.password) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
const isAdminPasswordValid = await bcrypt.compare(
|
||||||
|
inputs.currentPassword,
|
||||||
|
currentUser.password,
|
||||||
|
);
|
||||||
|
if (!isAdminPasswordValid) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user: updatedUser } = await User.qm.updateOne(user.id, {
|
||||||
|
totpSecret: null,
|
||||||
|
isTotpEnabled: false,
|
||||||
|
totpEnabledAt: null,
|
||||||
|
totpRecoveryCodes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sails.helpers.trustedDevices.deleteAllForUser.with({ userId: user.id });
|
||||||
|
|
||||||
|
if (!isSelf) {
|
||||||
|
await sails.helpers.sessions.invalidateAllForUser.with({ userId: user.id });
|
||||||
|
} else if (currentSession) {
|
||||||
|
await sails.helpers.sessions.invalidateAllForUser.with({
|
||||||
|
userId: user.id,
|
||||||
|
exceptSessionId: currentSession.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: sails.helpers.users.presentOne(updatedUser, currentUser),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /users/{id}/totp/enable:
|
||||||
|
* post:
|
||||||
|
* summary: Finalize TOTP enrollment
|
||||||
|
* description: Verifies the first TOTP code against the pending secret created via /totp/setup, persists the enabled flag, and returns one-time recovery codes.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* operationId: enableUserTotp
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - currentPassword
|
||||||
|
* - code
|
||||||
|
* properties:
|
||||||
|
* currentPassword:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 256
|
||||||
|
* code:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 16
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: TOTP enabled
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* item:
|
||||||
|
* $ref: '#/components/schemas/User'
|
||||||
|
* included:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* recoveryCodes:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/Unauthorized'
|
||||||
|
* 403:
|
||||||
|
* $ref: '#/components/responses/Forbidden'
|
||||||
|
* 404:
|
||||||
|
* $ref: '#/components/responses/NotFound'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const { idInput } = require('../../../utils/inputs');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
NOT_ENOUGH_RIGHTS: {
|
||||||
|
notEnoughRights: 'Not enough rights',
|
||||||
|
},
|
||||||
|
INVALID_CURRENT_PASSWORD: {
|
||||||
|
invalidCurrentPassword: 'Invalid current password',
|
||||||
|
},
|
||||||
|
INVALID_TOTP_CODE: {
|
||||||
|
invalidTotpCode: 'Invalid TOTP code',
|
||||||
|
},
|
||||||
|
TOTP_SETUP_NOT_INITIATED: {
|
||||||
|
totpSetupNotInitiated: 'TOTP setup has not been initiated',
|
||||||
|
},
|
||||||
|
USER_NOT_FOUND: {
|
||||||
|
userNotFound: 'User not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
id: {
|
||||||
|
...idInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currentPassword: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 256,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 16,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
notEnoughRights: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
invalidCurrentPassword: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
invalidTotpCode: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
totpSetupNotInitiated: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
userNotFound: {
|
||||||
|
responseType: 'notFound',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const { currentUser } = this.req;
|
||||||
|
|
||||||
|
if (inputs.id !== currentUser.id) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.qm.getOneById(inputs.id);
|
||||||
|
if (!user) {
|
||||||
|
throw Errors.USER_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.demoMode) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.totpSecret) {
|
||||||
|
throw Errors.TOTP_SETUP_NOT_INITIATED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCodeValid = sails.helpers.utils.verifyTotpCode.with({
|
||||||
|
code: inputs.code,
|
||||||
|
secret: user.totpSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isCodeValid) {
|
||||||
|
throw Errors.INVALID_TOTP_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plain: recoveryCodes, hashed: hashedRecoveryCodes } =
|
||||||
|
await sails.helpers.utils.generateRecoveryCodes();
|
||||||
|
|
||||||
|
const { user: updatedUser } = await User.qm.updateOne(user.id, {
|
||||||
|
isTotpEnabled: true,
|
||||||
|
totpEnabledAt: new Date().toISOString(),
|
||||||
|
totpRecoveryCodes: hashedRecoveryCodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: sails.helpers.users.presentOne(updatedUser, currentUser),
|
||||||
|
included: {
|
||||||
|
recoveryCodes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /users/{id}/trusted-devices:
|
||||||
|
* get:
|
||||||
|
* summary: List active trusted devices
|
||||||
|
* description: Returns the user's currently-valid trusted browsers. Only accessible for the user themselves.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* operationId: indexUserTrustedDevices
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of trusted devices
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/Unauthorized'
|
||||||
|
* 403:
|
||||||
|
* $ref: '#/components/responses/Forbidden'
|
||||||
|
* 404:
|
||||||
|
* $ref: '#/components/responses/NotFound'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { idInput } = require('../../../utils/inputs');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
NOT_ENOUGH_RIGHTS: {
|
||||||
|
notEnoughRights: 'Not enough rights',
|
||||||
|
},
|
||||||
|
USER_NOT_FOUND: {
|
||||||
|
userNotFound: 'User not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
id: {
|
||||||
|
...idInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
notEnoughRights: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
userNotFound: {
|
||||||
|
responseType: 'notFound',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const { currentUser } = this.req;
|
||||||
|
|
||||||
|
if (inputs.id !== currentUser.id) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.qm.getOneById(inputs.id);
|
||||||
|
if (!user) {
|
||||||
|
throw Errors.USER_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await TrustedDevice.qm.getActiveByUserId(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: devices.map((d) => sails.helpers.trustedDevices.presentOne(d)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /users/{id}/totp/recovery-codes:
|
||||||
|
* post:
|
||||||
|
* summary: Regenerate TOTP recovery codes
|
||||||
|
* description: Replaces the user's recovery code set with 10 freshly-generated codes. Requires the current password and a valid TOTP code.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* operationId: regenerateUserTotpRecoveryCodes
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - currentPassword
|
||||||
|
* - code
|
||||||
|
* properties:
|
||||||
|
* currentPassword:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 256
|
||||||
|
* code:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 16
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Recovery codes regenerated
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* included:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* recoveryCodes:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const { idInput } = require('../../../utils/inputs');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
NOT_ENOUGH_RIGHTS: {
|
||||||
|
notEnoughRights: 'Not enough rights',
|
||||||
|
},
|
||||||
|
INVALID_CURRENT_PASSWORD: {
|
||||||
|
invalidCurrentPassword: 'Invalid current password',
|
||||||
|
},
|
||||||
|
INVALID_TOTP_CODE: {
|
||||||
|
invalidTotpCode: 'Invalid TOTP code',
|
||||||
|
},
|
||||||
|
TOTP_NOT_ENABLED: {
|
||||||
|
totpNotEnabled: 'TOTP is not enabled',
|
||||||
|
},
|
||||||
|
USER_NOT_FOUND: {
|
||||||
|
userNotFound: 'User not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
id: {
|
||||||
|
...idInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currentPassword: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 256,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 16,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
notEnoughRights: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
invalidCurrentPassword: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
invalidTotpCode: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
totpNotEnabled: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
userNotFound: {
|
||||||
|
responseType: 'notFound',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const { currentUser } = this.req;
|
||||||
|
|
||||||
|
if (inputs.id !== currentUser.id) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.qm.getOneById(inputs.id);
|
||||||
|
if (!user) {
|
||||||
|
throw Errors.USER_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isTotpEnabled || !user.totpSecret) {
|
||||||
|
throw Errors.TOTP_NOT_ENABLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.demoMode) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCodeValid = sails.helpers.utils.verifyTotpCode.with({
|
||||||
|
code: inputs.code,
|
||||||
|
secret: user.totpSecret,
|
||||||
|
});
|
||||||
|
if (!isCodeValid) {
|
||||||
|
throw Errors.INVALID_TOTP_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plain: recoveryCodes, hashed: hashedRecoveryCodes } =
|
||||||
|
await sails.helpers.utils.generateRecoveryCodes();
|
||||||
|
|
||||||
|
await User.qm.updateOne(user.id, {
|
||||||
|
totpRecoveryCodes: hashedRecoveryCodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
included: {
|
||||||
|
recoveryCodes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /users/{id}/totp/setup:
|
||||||
|
* post:
|
||||||
|
* summary: Begin TOTP enrollment
|
||||||
|
* description: Generates a TOTP secret and provisioning URI for the authenticated user. The user is only fully enrolled after a successful call to /totp/enable.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* operationId: setupUserTotp
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - currentPassword
|
||||||
|
* properties:
|
||||||
|
* currentPassword:
|
||||||
|
* type: string
|
||||||
|
* maxLength: 256
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: TOTP setup initiated
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* item:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* secret:
|
||||||
|
* type: string
|
||||||
|
* provisioningUri:
|
||||||
|
* type: string
|
||||||
|
* 401:
|
||||||
|
* $ref: '#/components/responses/Unauthorized'
|
||||||
|
* 403:
|
||||||
|
* $ref: '#/components/responses/Forbidden'
|
||||||
|
* 404:
|
||||||
|
* $ref: '#/components/responses/NotFound'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const { idInput } = require('../../../utils/inputs');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
NOT_ENOUGH_RIGHTS: {
|
||||||
|
notEnoughRights: 'Not enough rights',
|
||||||
|
},
|
||||||
|
INVALID_CURRENT_PASSWORD: {
|
||||||
|
invalidCurrentPassword: 'Invalid current password',
|
||||||
|
},
|
||||||
|
USER_NOT_FOUND: {
|
||||||
|
userNotFound: 'User not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
id: {
|
||||||
|
...idInput,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currentPassword: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
maxLength: 256,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
notEnoughRights: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
invalidCurrentPassword: {
|
||||||
|
responseType: 'forbidden',
|
||||||
|
},
|
||||||
|
userNotFound: {
|
||||||
|
responseType: 'notFound',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const { currentUser } = this.req;
|
||||||
|
|
||||||
|
if (inputs.id !== currentUser.id) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.qm.getOneById(inputs.id);
|
||||||
|
if (!user) {
|
||||||
|
throw Errors.USER_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.demoMode) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
throw Errors.NOT_ENOUGH_RIGHTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(inputs.currentPassword, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw Errors.INVALID_CURRENT_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = sails.helpers.utils.generateTotpSecret();
|
||||||
|
const provisioningUri = sails.helpers.utils.buildTotpUri.with({
|
||||||
|
account: user.email,
|
||||||
|
secret,
|
||||||
|
issuer: 'Planka',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only write the pending secret. Leave isTotpEnabled / totpEnabledAt /
|
||||||
|
// totpRecoveryCodes alone — they only change in enable-totp / disable-totp.
|
||||||
|
// Otherwise calling setup-totp while TOTP is already enabled would silently
|
||||||
|
// disable 2FA on the server side (password alone could turn off the second factor).
|
||||||
|
await User.qm.updateOne(user.id, {
|
||||||
|
totpSecret: secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
secret,
|
||||||
|
provisioningUri,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { AccessTokenSteps } = require('../../../constants');
|
const { AccessTokenSteps, TRUST_DEVICE_COOKIE_NAME } = require('../../../constants');
|
||||||
|
|
||||||
const Errors = {
|
const Errors = {
|
||||||
ADMIN_LOGIN_REQUIRED_TO_INITIALIZE_INSTANCE: {
|
ADMIN_LOGIN_REQUIRED_TO_INITIALIZE_INSTANCE: {
|
||||||
@@ -39,6 +39,7 @@ module.exports = {
|
|||||||
exits: {
|
exits: {
|
||||||
adminLoginRequiredToInitializeInstance: {},
|
adminLoginRequiredToInitializeInstance: {},
|
||||||
termsAcceptanceRequired: {},
|
termsAcceptanceRequired: {},
|
||||||
|
totpVerificationRequired: {},
|
||||||
},
|
},
|
||||||
|
|
||||||
async fn(inputs) {
|
async fn(inputs) {
|
||||||
@@ -91,6 +92,53 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inputs.user.isTotpEnabled) {
|
||||||
|
const trustCookie =
|
||||||
|
inputs.request.cookies && inputs.request.cookies[TRUST_DEVICE_COOKIE_NAME];
|
||||||
|
|
||||||
|
const isDeviceTrusted = trustCookie
|
||||||
|
? await sails.helpers.trustedDevices.checkToken.with({
|
||||||
|
userId: inputs.user.id,
|
||||||
|
plainToken: trustCookie,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (!isDeviceTrusted) {
|
||||||
|
const { token: pendingToken, payload: pendingTokenPayload } =
|
||||||
|
sails.helpers.utils.createJwtToken(
|
||||||
|
AccessTokenSteps.VERIFY_TOTP,
|
||||||
|
undefined,
|
||||||
|
PENDING_TOKEN_EXPIRES_IN,
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = await sails.helpers.sessions.createOne.with({
|
||||||
|
values: {
|
||||||
|
pendingToken,
|
||||||
|
userId: inputs.user.id,
|
||||||
|
remoteAddress: inputs.remoteAddress,
|
||||||
|
userAgent: inputs.request.headers['user-agent'],
|
||||||
|
},
|
||||||
|
withHttpOnlyToken: inputs.withHttpOnlyToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.httpOnlyToken && !inputs.request.isSocket) {
|
||||||
|
sails.helpers.utils.setHttpOnlyTokenCookie(
|
||||||
|
session.httpOnlyToken,
|
||||||
|
pendingTokenPayload,
|
||||||
|
inputs.response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw {
|
||||||
|
totpVerificationRequired: {
|
||||||
|
pendingToken,
|
||||||
|
message: 'TOTP verification required',
|
||||||
|
step: AccessTokenSteps.VERIFY_TOTP,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
|
const { token: accessToken, payload: accessTokenPayload } = sails.helpers.utils.createJwtToken(
|
||||||
inputs.user.id,
|
inputs.user.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
userId: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
exceptSessionId: {
|
||||||
|
type: 'string',
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const criteria = {
|
||||||
|
userId: inputs.userId,
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
if (inputs.exceptSessionId) {
|
||||||
|
criteria.id = { '!=': inputs.exceptSessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await Session.find(criteria);
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Session.update(criteria).set({
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
if (session.accessToken) {
|
||||||
|
const roomName = `@accessToken:${session.accessToken}`;
|
||||||
|
sails.sockets.broadcast(roomName, 'logout');
|
||||||
|
sails.sockets.leaveAll(roomName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
userId: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
plainToken: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const candidates = await TrustedDevice.qm.getActiveByUserId(inputs.userId);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const matched = await bcrypt.compare(inputs.plainToken, candidate.tokenHash);
|
||||||
|
if (matched) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await TrustedDevice.qm.updateOne(
|
||||||
|
{ id: candidate.id },
|
||||||
|
{ lastUsedAt: new Date().toISOString() },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sails.log.warn(
|
||||||
|
`Failed to update lastUsedAt for trusted device ${candidate.id}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const { TRUST_DEVICE_EXPIRES_IN_DAYS } = require('../../../constants');
|
||||||
|
|
||||||
|
const TOKEN_BYTES = 32;
|
||||||
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
|
||||||
|
const buildUserAgentSummary = (userAgent) => {
|
||||||
|
if (!userAgent) return null;
|
||||||
|
return userAgent.length > 200 ? userAgent.slice(0, 200) : userAgent;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
userId: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: 'string',
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const plainToken = crypto.randomBytes(TOKEN_BYTES).toString('base64url');
|
||||||
|
const tokenHash = await bcrypt.hash(plainToken, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + TRUST_DEVICE_EXPIRES_IN_DAYS * 24 * 60 * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const fingerprint = sails.helpers.utils.parseUserAgent.with({ userAgent: inputs.userAgent });
|
||||||
|
|
||||||
|
const record = await TrustedDevice.qm.createOne({
|
||||||
|
userId: inputs.userId,
|
||||||
|
tokenHash,
|
||||||
|
userAgentSummary: buildUserAgentSummary(inputs.userAgent),
|
||||||
|
browserName: fingerprint.browserName,
|
||||||
|
browserVersion: fingerprint.browserVersion,
|
||||||
|
osName: fingerprint.osName,
|
||||||
|
osVersion: fingerprint.osVersion,
|
||||||
|
deviceType: fingerprint.deviceType,
|
||||||
|
deviceVendor: fingerprint.deviceVendor,
|
||||||
|
deviceModel: fingerprint.deviceModel,
|
||||||
|
expiresAt,
|
||||||
|
lastUsedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { record, plainToken };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
userId: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
await TrustedDevice.qm.deleteByUserId(inputs.userId);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sync: true,
|
||||||
|
|
||||||
|
inputs: {
|
||||||
|
record: {
|
||||||
|
type: 'ref',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fn(inputs) {
|
||||||
|
return _.omit(inputs.record, ['tokenHash']);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -17,6 +17,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
fn(inputs) {
|
fn(inputs) {
|
||||||
|
const recoveryCodes = inputs.record.totpRecoveryCodes;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
..._.omit(inputs.record, [
|
..._.omit(inputs.record, [
|
||||||
'password',
|
'password',
|
||||||
@@ -26,6 +28,8 @@ module.exports = {
|
|||||||
'passwordChangedAt',
|
'passwordChangedAt',
|
||||||
'apiKeyCreatedAt',
|
'apiKeyCreatedAt',
|
||||||
'termsAcceptedAt',
|
'termsAcceptedAt',
|
||||||
|
'totpSecret',
|
||||||
|
'totpRecoveryCodes',
|
||||||
]),
|
]),
|
||||||
avatar: inputs.record.avatar && {
|
avatar: inputs.record.avatar && {
|
||||||
url: `${sails.config.custom.baseUrl}/user-avatars/${inputs.record.avatar.uploadedFileId}/original.${inputs.record.avatar.extension}`,
|
url: `${sails.config.custom.baseUrl}/user-avatars/${inputs.record.avatar.uploadedFileId}/original.${inputs.record.avatar.extension}`,
|
||||||
@@ -34,6 +38,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
language: inputs.record.language || sails.config.i18n.defaultLocale,
|
language: inputs.record.language || sails.config.i18n.defaultLocale,
|
||||||
|
totpRecoveryCodesRemaining: Array.isArray(recoveryCodes) ? recoveryCodes.length : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const gravatarUrl = sails.helpers.users.buildGravatarUrl(inputs.record);
|
const gravatarUrl = sails.helpers.users.buildGravatarUrl(inputs.record);
|
||||||
@@ -68,7 +73,11 @@ module.exports = {
|
|||||||
return _.omit(data, User.PERSONAL_FIELD_NAMES);
|
return _.omit(data, User.PERSONAL_FIELD_NAMES);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _.omit(data, [...User.PRIVATE_FIELD_NAMES, ...User.PERSONAL_FIELD_NAMES]);
|
return _.omit(data, [
|
||||||
|
...User.PRIVATE_FIELD_NAMES,
|
||||||
|
...User.PERSONAL_FIELD_NAMES,
|
||||||
|
...User.TWO_FACTOR_VISIBLE_FIELD_NAMES,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { authenticator } = require('otplib');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sync: true,
|
||||||
|
|
||||||
|
inputs: {
|
||||||
|
account: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
secret: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
issuer: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fn(inputs) {
|
||||||
|
return authenticator.keyuri(inputs.account, inputs.issuer || 'Planka', inputs.secret);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const CODE_COUNT = 10;
|
||||||
|
const GROUP_LENGTH = 5;
|
||||||
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
|
||||||
|
// Largest multiple of CHARS.length that fits in one byte — anything above it
|
||||||
|
// would skew the character distribution via the modulo, so we rejection-sample.
|
||||||
|
const MAX_UNBIASED_BYTE = Math.floor(256 / CHARS.length) * CHARS.length;
|
||||||
|
|
||||||
|
const pickUnbiasedByte = () => {
|
||||||
|
// Loop bounded statistically: ~11% of bytes get rejected, so the expected
|
||||||
|
// number of draws per character is ~1.125.
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const [byte] = crypto.randomBytes(1);
|
||||||
|
if (byte < MAX_UNBIASED_BYTE) {
|
||||||
|
return byte;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCode = () => {
|
||||||
|
let left = '';
|
||||||
|
let right = '';
|
||||||
|
for (let i = 0; i < GROUP_LENGTH; i += 1) {
|
||||||
|
left += CHARS[pickUnbiasedByte() % CHARS.length];
|
||||||
|
right += CHARS[pickUnbiasedByte() % CHARS.length];
|
||||||
|
}
|
||||||
|
return `${left}-${right}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {},
|
||||||
|
|
||||||
|
async fn() {
|
||||||
|
const plain = [];
|
||||||
|
const hashed = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < CODE_COUNT; i += 1) {
|
||||||
|
const code = generateCode();
|
||||||
|
plain.push(code);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
hashed.push(await bcrypt.hash(code, BCRYPT_ROUNDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plain, hashed };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { authenticator } = require('otplib');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sync: true,
|
||||||
|
|
||||||
|
inputs: {},
|
||||||
|
|
||||||
|
fn() {
|
||||||
|
return authenticator.generateSecret();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UAParser = require('ua-parser-js');
|
||||||
|
|
||||||
|
const clip = (value, max = 64) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const s = String(value).trim();
|
||||||
|
if (!s) return null;
|
||||||
|
return s.length > max ? s.slice(0, max) : s;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sync: true,
|
||||||
|
|
||||||
|
inputs: {
|
||||||
|
userAgent: {
|
||||||
|
type: 'string',
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fn(inputs) {
|
||||||
|
if (!inputs.userAgent) {
|
||||||
|
return {
|
||||||
|
browserName: null,
|
||||||
|
browserVersion: null,
|
||||||
|
osName: null,
|
||||||
|
osVersion: null,
|
||||||
|
deviceType: null,
|
||||||
|
deviceVendor: null,
|
||||||
|
deviceModel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new UAParser(inputs.userAgent).getResult();
|
||||||
|
return {
|
||||||
|
browserName: clip(parsed.browser.name),
|
||||||
|
browserVersion: clip(parsed.browser.version),
|
||||||
|
osName: clip(parsed.os.name),
|
||||||
|
osVersion: clip(parsed.os.version),
|
||||||
|
deviceType: clip(parsed.device.type) || 'desktop',
|
||||||
|
deviceVendor: clip(parsed.device.vendor),
|
||||||
|
deviceModel: clip(parsed.device.model),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
browserName: null,
|
||||||
|
browserVersion: null,
|
||||||
|
osName: null,
|
||||||
|
osVersion: null,
|
||||||
|
deviceType: null,
|
||||||
|
deviceVendor: null,
|
||||||
|
deviceModel: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { authenticator } = require('otplib');
|
||||||
|
|
||||||
|
// Isolated instance: setting `authenticator.options = ...` would mutate the
|
||||||
|
// shared singleton and bleed into other callers (e.g. enable-totp's first verify,
|
||||||
|
// where a 90-second-wide acceptance window is too lenient).
|
||||||
|
const verifier = authenticator.clone();
|
||||||
|
verifier.options = { window: 1 };
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sync: true,
|
||||||
|
|
||||||
|
inputs: {
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
secret: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fn(inputs) {
|
||||||
|
try {
|
||||||
|
return verifier.verify({
|
||||||
|
token: inputs.code.replace(/\s+/g, ''),
|
||||||
|
secret: inputs.secret,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Query methods */
|
||||||
|
|
||||||
|
const createOne = (values) => TrustedDevice.create({ ...values }).fetch();
|
||||||
|
|
||||||
|
const getActiveByUserId = (userId) =>
|
||||||
|
TrustedDevice.find({
|
||||||
|
userId,
|
||||||
|
expiresAt: { '>': new Date().toISOString() },
|
||||||
|
}).sort('lastUsedAt DESC');
|
||||||
|
|
||||||
|
const updateOne = (criteria, values) => TrustedDevice.updateOne(criteria).set({ ...values });
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const delete_ = (criteria) => TrustedDevice.destroy(criteria).fetch();
|
||||||
|
|
||||||
|
const deleteByUserId = (userId) => TrustedDevice.destroy({ userId }).fetch();
|
||||||
|
|
||||||
|
const deleteOneByUserIdAndId = (userId, id) => TrustedDevice.destroyOne({ userId, id });
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createOne,
|
||||||
|
getActiveByUserId,
|
||||||
|
updateOne,
|
||||||
|
delete: delete_,
|
||||||
|
deleteByUserId,
|
||||||
|
deleteOneByUserIdAndId,
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrustedDevice.js
|
||||||
|
*
|
||||||
|
* @description :: Stores per-browser trust tokens that allow TOTP-protected users
|
||||||
|
* to skip the TOTP step for a limited time period.
|
||||||
|
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
attributes: {
|
||||||
|
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
|
||||||
|
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
|
||||||
|
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
|
||||||
|
|
||||||
|
tokenHash: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
columnName: 'token_hash',
|
||||||
|
},
|
||||||
|
userAgentSummary: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'user_agent_summary',
|
||||||
|
},
|
||||||
|
browserName: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'browser_name',
|
||||||
|
},
|
||||||
|
browserVersion: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'browser_version',
|
||||||
|
},
|
||||||
|
osName: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'os_name',
|
||||||
|
},
|
||||||
|
osVersion: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'os_version',
|
||||||
|
},
|
||||||
|
deviceType: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'device_type',
|
||||||
|
},
|
||||||
|
deviceVendor: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'device_vendor',
|
||||||
|
},
|
||||||
|
deviceModel: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'device_model',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: 'ref',
|
||||||
|
required: true,
|
||||||
|
columnName: 'expires_at',
|
||||||
|
},
|
||||||
|
lastUsedAt: {
|
||||||
|
type: 'ref',
|
||||||
|
columnName: 'last_used_at',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
||||||
|
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
||||||
|
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
|
||||||
|
|
||||||
|
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
|
||||||
|
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
|
||||||
|
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
|
||||||
|
|
||||||
|
userId: {
|
||||||
|
model: 'User',
|
||||||
|
required: true,
|
||||||
|
columnName: 'user_id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
tableName: 'trusted_device',
|
||||||
|
};
|
||||||
@@ -142,6 +142,21 @@
|
|||||||
* default: byDefault
|
* default: byDefault
|
||||||
* description: Default sort order for projects display (personal field)
|
* description: Default sort order for projects display (personal field)
|
||||||
* example: byDefault
|
* example: byDefault
|
||||||
|
* isTotpEnabled:
|
||||||
|
* type: boolean
|
||||||
|
* default: false
|
||||||
|
* description: Whether TOTP-based two-factor authentication is enabled (visible only to current user or admin)
|
||||||
|
* example: false
|
||||||
|
* totpEnabledAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* nullable: true
|
||||||
|
* description: When TOTP was enabled (visible only to current user or admin)
|
||||||
|
* example: 2026-05-14T10:00:00.000Z
|
||||||
|
* totpRecoveryCodesRemaining:
|
||||||
|
* type: integer
|
||||||
|
* description: Number of unused recovery codes (visible only to current user or admin)
|
||||||
|
* example: 10
|
||||||
* isDeactivated:
|
* isDeactivated:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* default: false
|
* default: false
|
||||||
@@ -233,7 +248,20 @@ const LANGUAGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// TODO: find better way to handle apiKeyHash and apiKeyCreatedAt
|
// TODO: find better way to handle apiKeyHash and apiKeyCreatedAt
|
||||||
const PRIVATE_FIELD_NAMES = ['email', 'apiKeyPrefix', 'apiKeyHash', 'apiKeyCreatedAt'];
|
const PRIVATE_FIELD_NAMES = [
|
||||||
|
'email',
|
||||||
|
'apiKeyPrefix',
|
||||||
|
'apiKeyHash',
|
||||||
|
'apiKeyCreatedAt',
|
||||||
|
'totpSecret',
|
||||||
|
'totpRecoveryCodes',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TWO_FACTOR_VISIBLE_FIELD_NAMES = [
|
||||||
|
'isTotpEnabled',
|
||||||
|
'totpEnabledAt',
|
||||||
|
'totpRecoveryCodesRemaining',
|
||||||
|
];
|
||||||
|
|
||||||
const PERSONAL_FIELD_NAMES = [
|
const PERSONAL_FIELD_NAMES = [
|
||||||
'language',
|
'language',
|
||||||
@@ -259,6 +287,7 @@ module.exports = {
|
|||||||
LANGUAGES,
|
LANGUAGES,
|
||||||
PRIVATE_FIELD_NAMES,
|
PRIVATE_FIELD_NAMES,
|
||||||
PERSONAL_FIELD_NAMES,
|
PERSONAL_FIELD_NAMES,
|
||||||
|
TWO_FACTOR_VISIBLE_FIELD_NAMES,
|
||||||
INTERNAL,
|
INTERNAL,
|
||||||
|
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -384,6 +413,25 @@ module.exports = {
|
|||||||
type: 'ref',
|
type: 'ref',
|
||||||
columnName: 'terms_accepted_at',
|
columnName: 'terms_accepted_at',
|
||||||
},
|
},
|
||||||
|
totpSecret: {
|
||||||
|
type: 'string',
|
||||||
|
isNotEmptyString: true,
|
||||||
|
allowNull: true,
|
||||||
|
columnName: 'totp_secret',
|
||||||
|
},
|
||||||
|
isTotpEnabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
defaultsTo: false,
|
||||||
|
columnName: 'is_totp_enabled',
|
||||||
|
},
|
||||||
|
totpEnabledAt: {
|
||||||
|
type: 'ref',
|
||||||
|
columnName: 'totp_enabled_at',
|
||||||
|
},
|
||||||
|
totpRecoveryCodes: {
|
||||||
|
type: 'json',
|
||||||
|
columnName: 'totp_recovery_codes',
|
||||||
|
},
|
||||||
|
|
||||||
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
||||||
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ module.exports.policies = {
|
|||||||
'users/update-username': 'is-authenticated',
|
'users/update-username': 'is-authenticated',
|
||||||
'users/update-avatar': 'is-authenticated',
|
'users/update-avatar': 'is-authenticated',
|
||||||
'users/create-api-key': ['is-authenticated', 'is-admin'],
|
'users/create-api-key': ['is-authenticated', 'is-admin'],
|
||||||
|
'users/setup-totp': 'is-authenticated',
|
||||||
|
'users/enable-totp': 'is-authenticated',
|
||||||
|
'users/disable-totp': 'is-authenticated',
|
||||||
|
'users/regenerate-totp-recovery-codes': 'is-authenticated',
|
||||||
|
'users/index-trusted-devices': 'is-authenticated',
|
||||||
|
'users/delete-trusted-device': 'is-authenticated',
|
||||||
'users/delete': ['is-authenticated', 'is-admin'],
|
'users/delete': ['is-authenticated', 'is-admin'],
|
||||||
|
|
||||||
'projects/create': ['is-authenticated', 'is-external', 'is-admin-or-project-owner'],
|
'projects/create': ['is-authenticated', 'is-external', 'is-admin-or-project-owner'],
|
||||||
@@ -49,6 +55,7 @@ module.exports.policies = {
|
|||||||
'bootstrap/show': true,
|
'bootstrap/show': true,
|
||||||
'terms/show': true,
|
'terms/show': true,
|
||||||
'access-tokens/create': true,
|
'access-tokens/create': true,
|
||||||
|
'access-tokens/verify-totp': true,
|
||||||
'access-tokens/accept-terms': true,
|
'access-tokens/accept-terms': true,
|
||||||
'access-tokens/revoke-pending-token': true,
|
'access-tokens/revoke-pending-token': true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ module.exports.routes = {
|
|||||||
'DELETE /api/webhooks/:id': 'webhooks/delete',
|
'DELETE /api/webhooks/:id': 'webhooks/delete',
|
||||||
|
|
||||||
'POST /api/access-tokens': 'access-tokens/create',
|
'POST /api/access-tokens': 'access-tokens/create',
|
||||||
|
'POST /api/access-tokens/verify-totp': 'access-tokens/verify-totp',
|
||||||
'POST /api/access-tokens/accept-terms': 'access-tokens/accept-terms',
|
'POST /api/access-tokens/accept-terms': 'access-tokens/accept-terms',
|
||||||
'POST /api/access-tokens/revoke-pending-token': 'access-tokens/revoke-pending-token',
|
'POST /api/access-tokens/revoke-pending-token': 'access-tokens/revoke-pending-token',
|
||||||
'DELETE /api/access-tokens/me': 'access-tokens/delete',
|
'DELETE /api/access-tokens/me': 'access-tokens/delete',
|
||||||
@@ -130,6 +131,12 @@ module.exports.routes = {
|
|||||||
'PATCH /api/users/:id/username': 'users/update-username',
|
'PATCH /api/users/:id/username': 'users/update-username',
|
||||||
'POST /api/users/:id/avatar': 'users/update-avatar',
|
'POST /api/users/:id/avatar': 'users/update-avatar',
|
||||||
'POST /api/users/:id/api-key': 'users/create-api-key',
|
'POST /api/users/:id/api-key': 'users/create-api-key',
|
||||||
|
'POST /api/users/:id/totp/setup': 'users/setup-totp',
|
||||||
|
'POST /api/users/:id/totp/enable': 'users/enable-totp',
|
||||||
|
'DELETE /api/users/:id/totp': 'users/disable-totp',
|
||||||
|
'POST /api/users/:id/totp/recovery-codes': 'users/regenerate-totp-recovery-codes',
|
||||||
|
'GET /api/users/:id/trusted-devices': 'users/index-trusted-devices',
|
||||||
|
'DELETE /api/users/:id/trusted-devices/:deviceId': 'users/delete-trusted-device',
|
||||||
'DELETE /api/users/:id': 'users/delete',
|
'DELETE /api/users/:id': 'users/delete',
|
||||||
|
|
||||||
'GET /api/projects': 'projects/index',
|
'GET /api/projects': 'projects/index',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const AccessTokenSteps = {
|
const AccessTokenSteps = {
|
||||||
ACCEPT_TERMS: 'accept-terms',
|
ACCEPT_TERMS: 'accept-terms',
|
||||||
|
VERIFY_TOTP: 'verify-totp',
|
||||||
};
|
};
|
||||||
|
|
||||||
const POSITION_GAP = 65536;
|
const POSITION_GAP = 65536;
|
||||||
@@ -7,9 +8,14 @@ const POSITION_GAP = 65536;
|
|||||||
const MAX_SIZE_TO_GET_ENCODING = 8 * 1024 * 1024;
|
const MAX_SIZE_TO_GET_ENCODING = 8 * 1024 * 1024;
|
||||||
const MAX_SIZE_TO_PROCESS_AS_IMAGE = 64 * 1024 * 1024;
|
const MAX_SIZE_TO_PROCESS_AS_IMAGE = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
const TRUST_DEVICE_COOKIE_NAME = 'planka-trust-token';
|
||||||
|
const TRUST_DEVICE_EXPIRES_IN_DAYS = 30;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
AccessTokenSteps,
|
AccessTokenSteps,
|
||||||
POSITION_GAP,
|
POSITION_GAP,
|
||||||
MAX_SIZE_TO_GET_ENCODING,
|
MAX_SIZE_TO_GET_ENCODING,
|
||||||
MAX_SIZE_TO_PROCESS_AS_IMAGE,
|
MAX_SIZE_TO_PROCESS_AS_IMAGE,
|
||||||
|
TRUST_DEVICE_COOKIE_NAME,
|
||||||
|
TRUST_DEVICE_EXPIRES_IN_DAYS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/*!
|
||||||
|
* Copyright (c) 2024 PLANKA Software GmbH
|
||||||
|
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports.up = async (knex) => {
|
||||||
|
await knex.schema.alterTable('user_account', (table) => {
|
||||||
|
table.text('totp_secret');
|
||||||
|
table.boolean('is_totp_enabled').notNullable().defaultTo(false);
|
||||||
|
table.timestamp('totp_enabled_at', true);
|
||||||
|
table.jsonb('totp_recovery_codes');
|
||||||
|
});
|
||||||
|
|
||||||
|
return knex.schema.createTable('trusted_device', (table) => {
|
||||||
|
/* Columns */
|
||||||
|
|
||||||
|
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
|
||||||
|
|
||||||
|
table.bigInteger('user_id').notNullable();
|
||||||
|
|
||||||
|
table.text('token_hash').notNullable();
|
||||||
|
table.text('user_agent_summary');
|
||||||
|
|
||||||
|
table.text('browser_name');
|
||||||
|
table.text('browser_version');
|
||||||
|
table.text('os_name');
|
||||||
|
table.text('os_version');
|
||||||
|
table.text('device_type');
|
||||||
|
table.text('device_vendor');
|
||||||
|
table.text('device_model');
|
||||||
|
table.text('label');
|
||||||
|
|
||||||
|
table.timestamp('expires_at', true).notNullable();
|
||||||
|
table.timestamp('last_used_at', true);
|
||||||
|
|
||||||
|
table.timestamp('created_at', true);
|
||||||
|
table.timestamp('updated_at', true);
|
||||||
|
|
||||||
|
/* Indexes */
|
||||||
|
|
||||||
|
table.index(['user_id', 'expires_at']);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.down = async (knex) => {
|
||||||
|
await knex.schema.dropTable('trusted_device');
|
||||||
|
|
||||||
|
return knex.schema.alterTable('user_account', (table) => {
|
||||||
|
table.dropColumn('totp_secret');
|
||||||
|
table.dropColumn('is_totp_enabled');
|
||||||
|
table.dropColumn('totp_enabled_at');
|
||||||
|
table.dropColumn('totp_recovery_codes');
|
||||||
|
});
|
||||||
|
};
|
||||||
Generated
+162
@@ -27,6 +27,7 @@
|
|||||||
"mime-types": "^3.0.2",
|
"mime-types": "^3.0.2",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"read": "^5.0.1",
|
"read": "^5.0.1",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"sails-postgresql": "^5.0.1",
|
"sails-postgresql": "^5.0.1",
|
||||||
"serve-static": "^2.2.1",
|
"serve-static": "^2.2.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
|
"ua-parser-js": "^2.0.10",
|
||||||
"undici": "^7.24.0",
|
"undici": "^7.24.0",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"validator": "^13.15.26",
|
"validator": "^13.15.26",
|
||||||
@@ -1241,6 +1243,56 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@otplib/core": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-crypto": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||||
|
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-thirty-two": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||||
|
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"thirty-two": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/preset-default": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||||
|
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"@otplib/plugin-crypto": "^12.0.1",
|
||||||
|
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/preset-v11": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"@otplib/plugin-crypto": "^12.0.1",
|
||||||
|
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@paralleldrive/cuid2": {
|
"node_modules/@paralleldrive/cuid2": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||||
@@ -2947,6 +2999,26 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-europe-js": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -5372,6 +5444,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-standalone-pwa": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
@@ -6971,6 +7063,17 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/otplib": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "^12.0.1",
|
||||||
|
"@otplib/preset-default": "^12.0.1",
|
||||||
|
"@otplib/preset-v11": "^12.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
||||||
@@ -9969,6 +10072,14 @@
|
|||||||
"url": "https://bevry.me/fund"
|
"url": "https://bevry.me/fund"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thirty-two": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.2.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tildify": {
|
"node_modules/tildify": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
|
||||||
@@ -10223,6 +10334,57 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ua-is-frozen": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ua-parser-js": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.10.tgz",
|
||||||
|
"integrity": "sha512-t+3Ktbq0Ies2vaSezfOaWiolH4OigQIO1dk+1xDpOydB1COVPocVYOrEV5rqZ0kFY9XYG1v9LutCyMgYBpABcw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-europe-js": "^0.1.2",
|
||||||
|
"is-standalone-pwa": "^0.1.1",
|
||||||
|
"ua-is-frozen": "^0.1.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ua-parser-js": "script/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uid-safe": {
|
"node_modules/uid-safe": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"mime-types": "^3.0.2",
|
"mime-types": "^3.0.2",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"read": "^5.0.1",
|
"read": "^5.0.1",
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
"sails-postgresql": "^5.0.1",
|
"sails-postgresql": "^5.0.1",
|
||||||
"serve-static": "^2.2.1",
|
"serve-static": "^2.2.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
|
"ua-parser-js": "^2.0.10",
|
||||||
"undici": "^7.24.0",
|
"undici": "^7.24.0",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"validator": "^13.15.26",
|
"validator": "^13.15.26",
|
||||||
|
|||||||
Reference in New Issue
Block a user