mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)
# Description of Changes This pull request introduces several improvements and new features across the authentication and admin data APIs, with a particular focus on multi-factor authentication (MFA) support and better handling of user settings. The changes include integrating MFA status into account data responses, masking sensitive user settings in admin views, and refactoring code to use more robust user creation methods. Additionally, there are minor code cleanups and consistency improvements. ### Multi-factor Authentication (MFA) Integration * Added `mfaEnabled` and `mfaRequired` fields to the `AccountData` response, populated using the new `MfaService`, to provide clients with MFA status information for users. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98) [[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575) ### User Settings Handling and Security * Admin settings API now returns user settings for each user, with the `mfaSecret` field masked to protect sensitive information. This is achieved by fetching settings via `findByIdWithSettings` and copying/masking the relevant field before returning. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563) ### Refactoring and Code Consistency * Refactored user creation in `InitialSecuritySetup` to use the `SaveUserRequest` builder and `saveUserCore` for better maintainability and clarity, replacing direct calls to `saveUser`. [[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22) [[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124) [[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144) [[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160) * Standardized checks for internal team membership by comparing with `TeamService.INTERNAL_TEAM_NAME` on the left side for consistency. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471) [[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485) ### API and DTO Changes * Changed the login API to accept `UsernameAndPassMfa` instead of `UsernameAndPass`, paving the way for MFA code support in authentication requests. [[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41) [[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71) * Updated import statements and controller dependencies to include new DTOs and services related to MFA. [[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19) [[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57) These updates improve security, prepare the system for MFA rollout, and make admin and authentication APIs more robust and informative. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -58,6 +58,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: undefined as number | undefined,
|
||||
authType: 'WEB' as 'WEB' | 'OAUTH2' | 'SAML2',
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
|
||||
// Form state for email invite
|
||||
@@ -141,6 +142,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: inviteForm.teamId,
|
||||
authType: inviteForm.authType,
|
||||
forceChange: inviteForm.forceChange,
|
||||
forceMFA: inviteForm.forceMFA,
|
||||
});
|
||||
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
|
||||
onClose();
|
||||
@@ -153,6 +155,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: undefined,
|
||||
authType: 'WEB',
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to invite user:', error);
|
||||
@@ -253,6 +256,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
teamId: undefined,
|
||||
authType: 'WEB',
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
setEmailInviteForm({
|
||||
emails: '',
|
||||
@@ -283,7 +287,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
handleInviteUser();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
@@ -543,6 +547,11 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
|
||||
checked={inviteForm.forceChange}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('workspace.people.addMember.forceMFA', 'Force MFA setup on next login')}
|
||||
checked={inviteForm.forceMFA}
|
||||
onChange={(e) => setInviteForm({ ...inviteForm, forceMFA: e.currentTarget.checked })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+298
-4
@@ -1,12 +1,15 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert as showToast } from '@app/components/toast';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useAccountLogout } from '@app/extensions/accountLogout';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -25,6 +28,17 @@ const AccountSection: React.FC = () => {
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
|
||||
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('');
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('');
|
||||
const [mfaError, setMfaError] = useState('');
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
const [changeButtonDisabled, setChangeButtonDisabled] = useState(false);
|
||||
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, '').slice(0, 6), []);
|
||||
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
|
||||
|
||||
const authTypeFromMetadata = useMemo(() => {
|
||||
const metadata = user?.app_metadata as { authType?: string; authenticationType?: string } | undefined;
|
||||
@@ -92,6 +106,124 @@ const AccountSection: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAccountData = async () => {
|
||||
setChangeButtonDisabled(true);
|
||||
try {
|
||||
const data = await accountService.getAccountData().then((data) => data).finally(() => {
|
||||
setChangeButtonDisabled(false);
|
||||
});
|
||||
setMfaEnabled(data.mfaEnabled ?? false);
|
||||
} catch {
|
||||
// ignore fetch errors for account data
|
||||
console.warn('Failed to fetch account data');
|
||||
} finally {
|
||||
setChangeButtonDisabled(false);
|
||||
}
|
||||
};
|
||||
void fetchAccountData();
|
||||
}, []);
|
||||
|
||||
const handleStartMfaSetup = useCallback(async () => {
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError('');
|
||||
setMfaSetupCode('');
|
||||
const data = await accountService.requestMfaSetup();
|
||||
setMfaSetupData(data);
|
||||
setMfaSetupModalOpen(true);
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t('account.mfa.setupFailed', 'Unable to start two-factor setup. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleEnableMfa = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!mfaSetupCode.trim()) {
|
||||
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError('');
|
||||
await accountService.enableMfa(mfaSetupCode.trim());
|
||||
setMfaEnabled(true);
|
||||
setMfaSetupModalOpen(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaSetupCode('');
|
||||
showToast({
|
||||
alertType: 'success',
|
||||
title: t('account.mfa.enabled', 'Two-factor authentication enabled.'),
|
||||
});
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t('account.mfa.enableFailed', 'Unable to enable two-factor authentication. Check the code and try again.')
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
},
|
||||
[mfaSetupCode, t]
|
||||
);
|
||||
|
||||
const handleDisableMfa = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!mfaDisableCode.trim()) {
|
||||
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError('');
|
||||
await accountService.disableMfa(mfaDisableCode.trim());
|
||||
setMfaEnabled(false);
|
||||
setMfaDisableModalOpen(false);
|
||||
setMfaDisableCode('');
|
||||
showToast({
|
||||
alertType: 'success',
|
||||
title: t('account.mfa.disabled', 'Two-factor authentication disabled.'),
|
||||
});
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t('account.mfa.disableFailed', 'Unable to disable two-factor authentication. Check the code and try again.')
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
},
|
||||
[mfaDisableCode, t]
|
||||
);
|
||||
|
||||
const handleCloseMfaSetupModal = useCallback(async () => {
|
||||
setMfaSetupModalOpen(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaSetupCode('');
|
||||
setMfaError('');
|
||||
try {
|
||||
await accountService.cancelMfaSetup();
|
||||
} catch {
|
||||
console.warn('Failed to clear pending MFA setup');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseMfaDisableModal = useCallback(() => {
|
||||
setMfaDisableModalOpen(false);
|
||||
setMfaDisableCode('');
|
||||
setMfaError('');
|
||||
}, []);
|
||||
|
||||
const handleUsernameSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -183,6 +315,50 @@ const AccountSection: React.FC = () => {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>{t('account.mfa.title', 'Two-factor authentication')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('account.mfa.description', 'Add an extra layer of security to your account.')}
|
||||
</Text>
|
||||
{isSsoUser ? (
|
||||
<Alert icon={<LocalIcon icon="info" width="1rem" height="1rem" />} color="blue" variant="light">
|
||||
{t(
|
||||
'account.mfa.ssoManaged',
|
||||
'Two-factor authentication for this account is managed by your identity provider.'
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Group gap="sm" wrap="wrap">
|
||||
{!mfaEnabled ? (
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="check-circle-outline-rounded" />}
|
||||
onClick={handleStartMfaSetup}
|
||||
loading={mfaLoading}
|
||||
disabled={changeButtonDisabled}
|
||||
>
|
||||
{t('account.mfa.enableButton', 'Enable two-factor authentication')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="close-rounded" />}
|
||||
onClick={() => {
|
||||
setMfaError('');
|
||||
setMfaDisableCode('');
|
||||
setMfaDisableModalOpen(true);
|
||||
}}
|
||||
disabled={changeButtonDisabled}
|
||||
>
|
||||
{t('account.mfa.disableButton', 'Disable two-factor authentication')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={passwordModalOpen}
|
||||
onClose={() => setPasswordModalOpen(false)}
|
||||
@@ -238,6 +414,124 @@ const AccountSection: React.FC = () => {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={mfaSetupModalOpen}
|
||||
onClose={handleCloseMfaSetupModal}
|
||||
title={t('account.mfa.setupTitle', 'Set up two-factor authentication')}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleEnableMfa}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'account.mfa.setupDescription',
|
||||
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.'
|
||||
)}
|
||||
</Text>
|
||||
{mfaSetupData && (
|
||||
<Stack gap="sm" align="center">
|
||||
<Box
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={mfaSetupData.otpauthUri || ''}
|
||||
size={180}
|
||||
level="H"
|
||||
imageSettings={{
|
||||
src: qrLogoSrc,
|
||||
height: 40,
|
||||
width: 40,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('account.mfa.manualKey', 'Manual setup key')}: <strong>{mfaSetupData.secret}</strong>
|
||||
</Text>
|
||||
<Text size="xs" c="orange">
|
||||
{t(
|
||||
'account.mfa.secretWarning',
|
||||
'Keep this key private. Anyone with access can generate valid authentication codes.'
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{mfaError && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t('account.mfa.codeLabel', 'Authentication code')}
|
||||
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
|
||||
value={mfaSetupCode}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={handleCloseMfaSetupModal}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" loading={mfaLoading}>
|
||||
{t('account.mfa.confirmEnable', 'Enable')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={mfaDisableModalOpen}
|
||||
onClose={handleCloseMfaDisableModal}
|
||||
title={t('account.mfa.disableTitle', 'Disable two-factor authentication')}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleDisableMfa}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('account.mfa.disableDescription', 'Enter a valid authentication code to disable two-factor authentication.')}
|
||||
</Text>
|
||||
{mfaError && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t('account.mfa.codeLabel', 'Authentication code')}
|
||||
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
|
||||
value={mfaDisableCode}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={handleCloseMfaDisableModal}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" color="red" loading={mfaLoading}>
|
||||
{t('account.mfa.confirmDisable', 'Disable')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={usernameModalOpen}
|
||||
onClose={() => setUsernameModalOpen(false)}
|
||||
@@ -261,7 +555,7 @@ const AccountSection: React.FC = () => {
|
||||
label={t('changeCreds.newUsername', 'New Username')}
|
||||
placeholder={t('changeCreds.newUsername', 'New Username')}
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.currentTarget.value)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -269,7 +563,7 @@ const AccountSection: React.FC = () => {
|
||||
label={t('changeCreds.oldPassword', 'Current Password')}
|
||||
placeholder={t('changeCreds.oldPassword', 'Current Password')}
|
||||
value={currentPasswordForUsername}
|
||||
onChange={(event) => setCurrentPasswordForUsername(event.currentTarget.value)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCurrentPasswordForUsername(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function PeopleSection() {
|
||||
...user,
|
||||
isActive: adminData.userSessions[user.username] || false,
|
||||
lastRequest: adminData.userLastRequest[user.username] || undefined,
|
||||
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === 'true',
|
||||
}));
|
||||
|
||||
setUsers(enrichedUsers);
|
||||
@@ -200,7 +201,7 @@ export default function PeopleSection() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch people data:', error);
|
||||
console.error('[PeopleSection] Failed to fetch people data:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load people data' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -221,7 +222,7 @@ export default function PeopleSection() {
|
||||
closeEditModal();
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update user:', error);
|
||||
console.error('[PeopleSection] Failed to update user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
@@ -238,7 +239,7 @@ export default function PeopleSection() {
|
||||
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to toggle user status:', error);
|
||||
console.error('[PeopleSection] Failed to toggle user status:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
@@ -258,7 +259,7 @@ export default function PeopleSection() {
|
||||
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete user:', error);
|
||||
console.error('[PeopleSection] Failed to delete user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
@@ -576,6 +577,7 @@ export default function PeopleSection() {
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions menu */}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" disabled={!loginEnabled}>
|
||||
@@ -589,9 +591,10 @@ export default function PeopleSection() {
|
||||
onClick={() => openEditModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.editRole')}
|
||||
{t('workspace.people.editRole', 'Edit Role & Team')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
@@ -599,6 +602,7 @@ export default function PeopleSection() {
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
|
||||
@@ -608,6 +612,31 @@ export default function PeopleSection() {
|
||||
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && user.mfaEnabled && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="key" width="1rem" height="1rem" />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await userManagementService.disableMfaByAdmin(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.mfa.adminDisableSuccess', 'MFA disabled successfully for user') });
|
||||
} catch (error: any) {
|
||||
console.error('[PeopleSection] Failed to disable MFA for user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.mfa.adminDisableError', 'Failed to disable MFA for user');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.mfa.disableByAdmin', 'Disable MFA')}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
@@ -618,6 +647,7 @@ export default function PeopleSection() {
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
Reference in New Issue
Block a user