feat: Add two-factor authentication via TOTP

Adds TOTP setup with QR code, login challenge, recovery codes and
trusted devices that let a browser skip the second factor for 30
days. Admins can reset another user's second factor by confirming
with their own password.
This commit is contained in:
Daniel Hiller
2026-08-07 20:11:55 +02:00
parent 36aa732fec
commit 2e4904f77d
74 changed files with 4173 additions and 4 deletions
@@ -0,0 +1,74 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import styles from './RecoveryCodesView.module.scss';
const RecoveryCodesView = React.memo(({ codes, className }) => {
const [t] = useTranslation();
const handleCopyClick = useCallback(() => {
const text = codes.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => {});
}
}, [codes]);
const handleDownloadClick = useCallback(() => {
const text = codes.join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'planka-recovery-codes.txt';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [codes]);
return (
<div className={[styles.wrapper, className].filter(Boolean).join(' ')}>
<ul className={styles.list}>
{codes.map((code) => (
<li key={code}>
<code>{code}</code>
</li>
))}
</ul>
<div className={styles.actions}>
<Button
basic
size="small"
icon="copy"
content={t('action.copyAll')}
onClick={handleCopyClick}
/>
<Button
basic
size="small"
icon="download"
content={t('action.download')}
onClick={handleDownloadClick}
/>
</div>
</div>
);
});
RecoveryCodesView.propTypes = {
codes: PropTypes.arrayOf(PropTypes.string).isRequired,
className: PropTypes.string,
};
RecoveryCodesView.defaultProps = {
className: undefined,
};
export default RecoveryCodesView;
@@ -0,0 +1,33 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
background: var(--surface-raised);
border: 1px solid var(--grey-e0e0e0);
border-radius: 6px;
padding: 12px;
}
.list {
column-count: 2;
column-gap: 16px;
font-family: 'JetBrains Mono', 'Menlo', 'Courier New', monospace;
font-size: 14px;
list-style: none;
margin: 0 0 12px;
padding: 0;
li {
break-inside: avoid;
padding: 2px 0;
}
}
.actions {
display: flex;
gap: 8px;
}
}
@@ -0,0 +1,124 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Header, Icon, Message, Tab } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import TotpSetupWizard from './TotpSetupWizard';
import TotpDisableModal from './TotpDisableModal';
import TotpRecoveryCodesModal from './TotpRecoveryCodesModal';
import RecoveryCodesView from './RecoveryCodesView';
import TrustedDevicesSection from './TrustedDevicesSection';
import styles from './SecurityPane.module.scss';
const SecurityPane = React.memo(() => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false);
const handleEnableClick = useCallback(() => {
setIsWizardOpen(true);
}, []);
const handleWizardClose = useCallback(() => {
setIsWizardOpen(false);
dispatch(entryActions.clearCurrentUserTotpSetupValue());
}, [dispatch]);
const handleDisableClick = useCallback(() => {
setIsDisableModalOpen(true);
}, []);
const handleDisableClose = useCallback(() => {
setIsDisableModalOpen(false);
}, []);
const handleRegenerateClick = useCallback(() => {
setIsRegenerateModalOpen(true);
}, []);
const handleRegenerateClose = useCallback(() => {
setIsRegenerateModalOpen(false);
dispatch(entryActions.clearCurrentUserTotpRecoveryCodes());
}, [dispatch]);
const totpState = user.totpState || {};
const showRecoveryCodes = totpState.recoveryCodes && totpState.recoveryCodes.length > 0;
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Header as="h3">{t('common.twoFactorAuthentication')}</Header>
{user.isTotpEnabled ? (
<>
<Message positive>
<Icon name="shield" />
<span>{t('common.twoFactor_enabled')}</span>
{user.totpEnabledAt && (
<span className={styles.enabledMeta}>
{' — '}
{t('common.enabledOn', {
date: new Date(user.totpEnabledAt).toLocaleString(),
})}
</span>
)}
</Message>
{typeof user.totpRecoveryCodesRemaining === 'number' &&
user.totpRecoveryCodesRemaining <= 3 && (
<Message warning>
<Icon name="warning sign" />
{user.totpRecoveryCodesRemaining === 0
? t('common.recoveryCodesExhausted')
: t('common.recoveryCodesLow', { count: user.totpRecoveryCodesRemaining })}
</Message>
)}
<div className={styles.actionRow}>
<Button
basic
icon="refresh"
content={t('action.regenerateRecoveryCodes')}
onClick={handleRegenerateClick}
/>
<Button
negative
icon="shield alternate"
content={t('action.disable2fa')}
onClick={handleDisableClick}
/>
</div>
{showRecoveryCodes && (
<RecoveryCodesView codes={totpState.recoveryCodes} className={styles.recoverySection} />
)}
<TrustedDevicesSection />
</>
) : (
<>
<p className={styles.intro}>{t('common.twoFactor_intro')}</p>
<Button
primary
icon="shield"
content={t('action.enable2fa')}
onClick={handleEnableClick}
/>
</>
)}
{isWizardOpen && <TotpSetupWizard onClose={handleWizardClose} />}
{isDisableModalOpen && <TotpDisableModal onClose={handleDisableClose} />}
{isRegenerateModalOpen && <TotpRecoveryCodesModal onClose={handleRegenerateClose} />}
</Tab.Pane>
);
});
export default SecurityPane;
@@ -0,0 +1,32 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
.intro {
color: var(--text-secondary);
margin-bottom: 16px;
}
.enabledMeta {
color: var(--text-secondary);
font-weight: normal;
}
.actionRow {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.recoverySection {
margin-top: 20px;
}
}
@@ -0,0 +1,99 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message, Modal } from 'semantic-ui-react';
import { Input } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
const TotpDisableModal = React.memo(({ onClose }) => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const totpState = user.totpState || {};
const { isDisabling, error } = totpState;
const wasDisablingRef = useRef(false);
useEffect(() => {
if (wasDisablingRef.current && !isDisabling && !error && !user.isTotpEnabled) {
onClose();
}
wasDisablingRef.current = isDisabling;
}, [isDisabling, error, user.isTotpEnabled, onClose]);
const handleSubmit = useCallback(() => {
if (!password || !code) return;
dispatch(
entryActions.disableCurrentUserTotp({
currentPassword: password,
code: code.replace(/\s+/g, ''),
}),
);
}, [dispatch, password, code]);
return (
<Modal open centered size="tiny" closeOnDimmerClick={false} onClose={onClose}>
<Modal.Header>{t('common.disable2fa_title')}</Modal.Header>
<Modal.Content>
<p>{t('common.disable2faWarning')}</p>
{error && error.message === 'Invalid current password' && (
<Message error content={t('common.invalidCurrentPassword')} />
)}
{error && error.message === 'Invalid TOTP code' && (
<Message error content={t('common.invalidTotpCode')} />
)}
<Form onSubmit={handleSubmit}>
<Form.Field>
<label htmlFor="totp-disable-password">{t('common.currentPassword')}</label>
<Input.Password
fluid
id="totp-disable-password"
value={password}
maxLength={256}
onChange={(_, { value }) => setPassword(value)}
/>
</Form.Field>
<Form.Field>
<label htmlFor="totp-disable-code">{t('common.totpOrRecoveryCode')}</label>
<Input
fluid
id="totp-disable-code"
value={code}
maxLength={16}
placeholder="000000 / xxxxx-xxxxx"
autoComplete="one-time-code"
onChange={(_, { value }) => setCode(value)}
/>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Actions>
<Button content={t('action.cancel')} floated="left" onClick={onClose} />
<Button
negative
content={t('action.disable2fa')}
loading={isDisabling}
disabled={isDisabling || !password || !code}
onClick={handleSubmit}
/>
</Modal.Actions>
</Modal>
);
});
TotpDisableModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default TotpDisableModal;
@@ -0,0 +1,114 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Form, Message, Modal } from 'semantic-ui-react';
import { Input } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import RecoveryCodesView from './RecoveryCodesView';
const TotpRecoveryCodesModal = React.memo(({ onClose }) => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const totpState = user.totpState || {};
const { isRegeneratingRecoveryCodes, error, recoveryCodes } = totpState;
const handleSubmit = useCallback(() => {
if (!password || !code) return;
dispatch(
entryActions.regenerateCurrentUserTotpRecoveryCodes({
currentPassword: password,
code: code.replace(/\s+/g, ''),
}),
);
}, [dispatch, password, code]);
const hasCodes = recoveryCodes && recoveryCodes.length > 0;
return (
<Modal open centered size="small" closeOnDimmerClick={false} onClose={onClose}>
<Modal.Header>{t('common.regenerateRecoveryCodes_title')}</Modal.Header>
<Modal.Content>
{!hasCodes && (
<>
<p>{t('common.regenerateRecoveryCodesIntro')}</p>
{error && error.message === 'Invalid current password' && (
<Message error content={t('common.invalidCurrentPassword')} />
)}
{error && error.message === 'Invalid TOTP code' && (
<Message error content={t('common.invalidTotpCode')} />
)}
<Form onSubmit={handleSubmit}>
<Form.Field>
<label htmlFor="totp-regen-password">{t('common.currentPassword')}</label>
<Input.Password
fluid
id="totp-regen-password"
value={password}
maxLength={256}
onChange={(_, { value }) => setPassword(value)}
/>
</Form.Field>
<Form.Field>
<label htmlFor="totp-regen-code">{t('common.totpCode')}</label>
<Input
fluid
id="totp-regen-code"
value={code}
maxLength={8}
placeholder="000000"
autoComplete="one-time-code"
onChange={(_, { value }) => setCode(value)}
/>
</Form.Field>
</Form>
</>
)}
{hasCodes && (
<>
<Message warning>
<Message.Header>{t('common.saveTheseCodes_title')}</Message.Header>
<p>{t('common.recoveryCodesIntro')}</p>
</Message>
<RecoveryCodesView codes={recoveryCodes} />
</>
)}
</Modal.Content>
<Modal.Actions>
{!hasCodes ? (
<>
<Button content={t('action.cancel')} floated="left" onClick={onClose} />
<Button
positive
content={t('action.regenerate')}
loading={isRegeneratingRecoveryCodes}
disabled={isRegeneratingRecoveryCodes || !password || !code}
onClick={handleSubmit}
/>
</>
) : (
<Button positive content={t('action.done')} onClick={onClose} />
)}
</Modal.Actions>
</Modal>
);
});
TotpRecoveryCodesModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default TotpRecoveryCodesModal;
@@ -0,0 +1,210 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { QRCodeSVG } from 'qrcode.react';
import { Button, Checkbox, Form, Message, Modal } from 'semantic-ui-react';
import { Input } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import RecoveryCodesView from './RecoveryCodesView';
import styles from './TotpSetupWizard.module.scss';
const STEPS = {
PASSWORD: 'password',
SCAN: 'scan',
VERIFY: 'verify',
CODES: 'codes',
};
const TotpSetupWizard = React.memo(({ onClose }) => {
const user = useSelector(selectors.selectCurrentUser);
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, setStep] = useState(STEPS.PASSWORD);
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [savedConfirmed, setSavedConfirmed] = useState(false);
const totpState = user.totpState || {};
const { setupSecret, setupProvisioningUri, isSettingUp, isEnabling, recoveryCodes, error } =
totpState;
useEffect(() => {
if (step === STEPS.SCAN && setupSecret && setupProvisioningUri) {
// Already initialized
}
}, [step, setupSecret, setupProvisioningUri]);
// Once setup succeeds, advance to scan
useEffect(() => {
if (step === STEPS.PASSWORD && setupSecret && setupProvisioningUri) {
setStep(STEPS.SCAN);
}
}, [step, setupSecret, setupProvisioningUri]);
// Once enable succeeds (recovery codes appear), advance to codes screen
useEffect(() => {
if (recoveryCodes && recoveryCodes.length > 0 && step === STEPS.VERIFY) {
setStep(STEPS.CODES);
}
}, [recoveryCodes, step]);
const handlePasswordSubmit = useCallback(() => {
if (!password) return;
dispatch(entryActions.setupCurrentUserTotp({ currentPassword: password }));
}, [dispatch, password]);
const handleProceedToVerify = useCallback(() => {
setStep(STEPS.VERIFY);
}, []);
const handleVerifySubmit = useCallback(() => {
const trimmed = code.replace(/\s+/g, '');
if (!trimmed) return;
dispatch(
entryActions.enableCurrentUserTotp({
currentPassword: password,
code: trimmed,
}),
);
}, [dispatch, code, password]);
const handleBackToScan = useCallback(() => {
setCode('');
setStep(STEPS.SCAN);
}, []);
const handleDone = useCallback(() => {
onClose();
}, [onClose]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
return (
<Modal open centered size="small" closeOnDimmerClick={false} onClose={handleCancel}>
<Modal.Header>{t('common.enable2fa_title')}</Modal.Header>
<Modal.Content>
{step === STEPS.PASSWORD && (
<Form onSubmit={handlePasswordSubmit}>
<p className={styles.intro}>{t('common.enterPasswordToContinue')}</p>
{error && error.message === 'Invalid current password' && (
<Message error content={t('common.invalidCurrentPassword')} />
)}
<Form.Field>
<label htmlFor="totp-current-password">{t('common.currentPassword')}</label>
<Input.Password
fluid
id="totp-current-password"
value={password}
maxLength={256}
onChange={(_, { value }) => setPassword(value)}
/>
</Form.Field>
</Form>
)}
{step === STEPS.SCAN && setupProvisioningUri && (
<div className={styles.scanStep}>
<p>{t('common.scanQrCodeWithApp')}</p>
<div className={styles.qrWrapper}>
<QRCodeSVG value={setupProvisioningUri} size={200} level="M" />
</div>
<p className={styles.secretLabel}>{t('common.orEnterSecretManually')}</p>
<code className={styles.secret}>{setupSecret}</code>
</div>
)}
{step === STEPS.VERIFY && (
<Form onSubmit={handleVerifySubmit}>
<p>{t('common.enterCodeFromApp')}</p>
{error && error.message === 'Invalid TOTP code' && (
<Message error content={t('common.invalidTotpCode')} />
)}
<Form.Field>
<Input
fluid
autoFocus
value={code}
maxLength={8}
placeholder="000000"
autoComplete="one-time-code"
className={styles.codeInput}
onChange={(_, { value }) => setCode(value)}
/>
</Form.Field>
</Form>
)}
{step === STEPS.CODES && recoveryCodes && (
<div className={styles.codesStep}>
<Message warning>
<Message.Header>{t('common.saveTheseCodes_title')}</Message.Header>
<p>{t('common.recoveryCodesIntro')}</p>
</Message>
<RecoveryCodesView codes={recoveryCodes} />
<Checkbox
label={t('common.confirmCodesSaved')}
checked={savedConfirmed}
className={styles.savedCheckbox}
onChange={(_, { checked }) => setSavedConfirmed(checked)}
/>
</div>
)}
</Modal.Content>
<Modal.Actions>
{step !== STEPS.CODES && (
<Button content={t('action.cancel')} floated="left" onClick={handleCancel} />
)}
{step === STEPS.PASSWORD && (
<Button
positive
content={t('action.continue')}
loading={isSettingUp}
disabled={isSettingUp || !password}
onClick={handlePasswordSubmit}
/>
)}
{step === STEPS.SCAN && (
<Button positive content={t('action.continue')} onClick={handleProceedToVerify} />
)}
{step === STEPS.VERIFY && (
<>
<Button content={t('action.back')} onClick={handleBackToScan} />
<Button
positive
content={t('action.verify')}
loading={isEnabling}
disabled={isEnabling || !code}
onClick={handleVerifySubmit}
/>
</>
)}
{step === STEPS.CODES && (
<Button
positive
content={t('action.done')}
disabled={!savedConfirmed}
onClick={handleDone}
/>
)}
</Modal.Actions>
</Modal>
);
});
TotpSetupWizard.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default TotpSetupWizard;
@@ -0,0 +1,59 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.intro {
color: var(--text-secondary);
margin-bottom: 12px;
}
.scanStep {
text-align: center;
}
.qrWrapper {
background: var(--surface-card);
border: 1px solid var(--grey-dddddd);
border-radius: 8px;
display: inline-block;
margin: 16px 0;
padding: 16px;
}
.secretLabel {
color: var(--text-secondary);
font-size: 13px;
margin-bottom: 6px;
}
.secret {
background: var(--surface-subtle);
border: 1px solid var(--grey-dddddd);
border-radius: 4px;
display: inline-block;
font-family: 'JetBrains Mono', 'Menlo', 'Courier New', monospace;
font-size: 14px;
letter-spacing: 1px;
padding: 6px 12px;
user-select: all;
}
.codeInput {
font-family: 'JetBrains Mono', 'Menlo', 'Courier New', monospace;
font-size: 18px;
letter-spacing: 2px;
text-align: center;
}
.codesStep {
display: flex;
flex-direction: column;
gap: 12px;
}
.savedCheckbox {
margin-top: 8px;
}
}
@@ -0,0 +1,142 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Header, Icon, List, Loader, Message } from 'semantic-ui-react';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import styles from './TrustedDevicesSection.module.scss';
const formatLastUsed = (iso, t) => {
if (!iso) return t('common.never');
return new Date(iso).toLocaleString();
};
const ICON_BY_DEVICE_TYPE = {
mobile: 'mobile alternate',
tablet: 'tablet alternate',
smarttv: 'tv',
wearable: 'heartbeat',
console: 'gamepad',
};
const getDeviceIcon = (device) => ICON_BY_DEVICE_TYPE[device.deviceType] || 'desktop';
const buildPrimaryLabel = (device, t) => {
if (device.label) return device.label;
// Mobile / tablet: vendor + model is usually the most recognizable ("Apple iPhone")
if (device.deviceVendor || device.deviceModel) {
const joined = [device.deviceVendor, device.deviceModel].filter(Boolean).join(' ');
if (joined) return joined;
}
// Desktop fallback: OS line
const osPart = [device.osName, device.osVersion].filter(Boolean).join(' ');
if (osPart) return osPart;
if (device.userAgentSummary) return device.userAgentSummary;
return t('common.unknownDevice');
};
const buildSecondaryLabel = (device) => {
const parts = [];
const hasMobileLabel = !!(device.deviceVendor || device.deviceModel);
// If we already showed OS as the primary label (desktop fallback), don't repeat it.
if (hasMobileLabel && (device.osName || device.osVersion)) {
parts.push([device.osName, device.osVersion].filter(Boolean).join(' '));
}
if (device.browserName) {
parts.push([device.browserName, device.browserVersion].filter(Boolean).join(' '));
}
return parts.filter(Boolean).join(' · ');
};
const TrustedDevicesSection = React.memo(() => {
const { items, isFetching, isFetched, deletingIds } = useSelector(
selectors.selectUserTrustedDevicesState,
);
const dispatch = useDispatch();
const [t] = useTranslation();
useEffect(() => {
dispatch(entryActions.fetchCurrentUserTrustedDevices());
}, [dispatch]);
const handleRevokeClick = useCallback(
(deviceId) => {
dispatch(entryActions.deleteCurrentUserTrustedDevice(deviceId));
},
[dispatch],
);
return (
<div className={styles.wrapper}>
<Header as="h4" className={styles.heading}>
{t('common.trustedDevices_title')}
</Header>
<p className={styles.hint}>{t('common.trustedDevicesHint')}</p>
{!isFetched && isFetching && <Loader active inline="centered" size="small" />}
{isFetched && items.length === 0 && <Message info content={t('common.noTrustedDevices')} />}
{items.length > 0 && (
<List divided relaxed className={styles.list}>
{items.map((device) => {
const isDeleting = deletingIds.includes(device.id);
const primary = buildPrimaryLabel(device, t);
const secondary = buildSecondaryLabel(device);
return (
<List.Item key={device.id}>
<List.Content floated="right">
<Button
basic
size="tiny"
icon="trash"
content={t('action.revoke')}
loading={isDeleting}
disabled={isDeleting}
onClick={() => handleRevokeClick(device.id)}
/>
</List.Content>
<List.Content>
<List.Header className={styles.deviceHeader}>
<Icon name={getDeviceIcon(device)} />
<span className={styles.devicePrimary}>{primary}</span>
</List.Header>
{secondary && (
<List.Description className={styles.deviceSecondary}>
{secondary}
</List.Description>
)}
<List.Description className={styles.meta}>
<span>
{t('common.lastUsed')}: {formatLastUsed(device.lastUsedAt, t)}
</span>
{' · '}
<span>
{t('common.expires')}: {new Date(device.expiresAt).toLocaleDateString()}
</span>
</List.Description>
</List.Content>
</List.Item>
);
})}
</List>
)}
</div>
);
});
export default TrustedDevicesSection;
@@ -0,0 +1,45 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
margin-top: 24px;
}
.heading {
margin-bottom: 8px !important;
}
.hint {
color: var(--text-tertiary);
margin-bottom: 12px;
}
.list {
margin: 0 !important;
}
.deviceHeader {
display: flex;
align-items: center;
gap: 6px;
}
.devicePrimary {
font-weight: 600;
}
.deviceSecondary {
color: var(--text-secondary) !important;
font-size: 13px;
margin-top: 2px !important;
}
.meta {
color: var(--text-tertiary) !important;
font-size: 12px;
margin-top: 4px;
}
}
@@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import SecurityPane from './SecurityPane';
export default SecurityPane;
@@ -13,6 +13,7 @@ import { useClosableModal } from '../../../hooks';
import AccountPane from './AccountPane';
import PreferencesPane from './PreferencesPane';
import NotificationsPane from './NotificationsPane';
import SecurityPane from './SecurityPane';
const UserSettingsModal = React.memo(() => {
const dispatch = useDispatch();
@@ -43,6 +44,12 @@ const UserSettingsModal = React.memo(() => {
}),
render: () => <NotificationsPane />,
},
{
menuItem: t('common.security', {
context: 'title',
}),
render: () => <SecurityPane />,
},
];
return (