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:
Ludy
2026-01-23 21:34:57 +00:00
committed by GitHub
co-authored by Copilot
parent 0436460c03
commit 0b86dd79d3
52 changed files with 3739 additions and 558 deletions
@@ -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 })}
/>
</>
)}
@@ -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>