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:
@@ -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 PreferencesPane from './PreferencesPane';
|
||||
import NotificationsPane from './NotificationsPane';
|
||||
import SecurityPane from './SecurityPane';
|
||||
|
||||
const UserSettingsModal = React.memo(() => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -43,6 +44,12 @@ const UserSettingsModal = React.memo(() => {
|
||||
}),
|
||||
render: () => <NotificationsPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.security', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <SecurityPane />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user