Add admin controls for requiring MFA per user

Introduces the ability for admins to require or make optional multi-factor authentication (MFA) for individual users in the PeopleSection UI. Updates the userManagementService with a new requireMfaByAdmin method and extends the User interface to include mfaRequired. The UI now displays MFA status and provides actions to set MFA as required or optional for users.
This commit is contained in:
Ludy87
2026-01-29 18:51:26 +01:00
parent cd00dea6f1
commit 119ffbbf2f
2 changed files with 372 additions and 228 deletions
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import {
Stack,
@@ -17,21 +17,21 @@ import {
CloseButton,
Avatar,
Box,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService, User } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useNavigate } from 'react-router-dom';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
import { useAuth } from '@app/auth/UseSession';
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { userManagementService, User } from "@app/services/userManagementService";
import { teamService, Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import InviteMembersModal from "@app/components/shared/InviteMembersModal";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useNavigate } from "react-router-dom";
import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
import { useLicense } from "@app/contexts/LicenseContext";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
import { useAuth } from "@app/auth/UseSession";
export default function PeopleSection() {
const { t } = useTranslation();
@@ -43,7 +43,7 @@ export default function PeopleSection() {
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
@@ -67,52 +67,90 @@ export default function PeopleSection() {
return;
}
if (hasNoSlots) {
navigate('/settings/adminPlan');
navigate("/settings/adminPlan");
return;
}
setInviteModalOpened(true);
};
const addMemberTooltip = !loginEnabled
? t('workspace.people.loginRequired', 'Enable login mode first')
? t("workspace.people.loginRequired", "Enable login mode first")
: hasNoSlots
? t('workspace.people.license.noSlotsAvailable', 'No user slots available')
? t("workspace.people.license.noSlotsAvailable", "No user slots available")
: null;
const isCurrentUser = (user: User) => currentUser?.username === user.username;
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
role: "ROLE_USER",
teamId: undefined as number | undefined,
});
useEffect(() => {
fetchData();
(async () => {
await fetchData();
})();
}, []);
useEffect(() => {
if (config) {
console.log('[PeopleSection] Email invites enabled:', config.enableEmailInvites);
console.log("[PeopleSection] Email invites enabled:", config.enableEmailInvites);
}
}, [config]);
const handleClickButton = async (user: User) => {
// Warte auf fetchData Abschluss
console.log(
"[PeopleSection] klick1",
user.mfaRequired,
users.find((u) => u.username === user.username),
);
try {
if (!user.mfaRequired) {
await userManagementService.requireMfaByAdmin(user.username, true);
console.log("[PeopleSection] klick3");
alert({
alertType: "success",
title: t("workspace.people.mfa.adminEnableSuccess", "MFA enabled successfully for user"),
});
} else {
await userManagementService.requireMfaByAdmin(user.username, false);
console.log("[PeopleSection] klick4");
alert({
alertType: "success",
title: t("workspace.people.mfa.adminDisableSuccess", "MFA disabled successfully for user"),
});
}
} catch (error: any) {
console.error("[PeopleSection] Failed to enable MFA for user:", error);
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t("workspace.people.mfa.adminEnableError", "Failed to enable MFA for user");
alert({ alertType: "error", title: errorMessage });
}
await fetchData();
console.log("[PeopleSection] klick1", user.mfaRequired);
};
const fetchData = async () => {
try {
setLoading(true);
if (loginEnabled) {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
const [adminData, teamsData] = await Promise.all([userManagementService.getUsers(), teamService.getTeams()]);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
const enrichedUsers = adminData.users.map((user) => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === 'true',
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === "true",
mfaRequired: adminData.userSettings?.[user.username]?.mfaRequired === "true",
}));
setUsers(enrichedUsers);
@@ -133,57 +171,61 @@ export default function PeopleSection() {
const exampleUsers: User[] = [
{
id: 1,
username: 'admin',
email: 'admin@example.com',
username: "admin",
email: "admin@example.com",
enabled: true,
roleName: 'ROLE_ADMIN',
rolesAsString: 'ROLE_ADMIN',
authenticationType: 'password',
roleName: "ROLE_ADMIN",
rolesAsString: "ROLE_ADMIN",
authenticationType: "password",
isActive: true,
lastRequest: Date.now(),
team: { id: 1, name: 'Engineering' }
team: { id: 1, name: "Engineering" },
mfaRequired: true,
},
{
id: 2,
username: 'john.doe',
email: 'john.doe@example.com',
username: "john.doe",
email: "john.doe@example.com",
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 86400000,
team: { id: 1, name: 'Engineering' }
team: { id: 1, name: "Engineering" },
mfaRequired: true,
},
{
id: 3,
username: 'jane.smith',
email: 'jane.smith@example.com',
username: "jane.smith",
email: "jane.smith@example.com",
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'oauth',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "oauth",
isActive: true,
lastRequest: Date.now(),
team: { id: 2, name: 'Marketing' }
team: { id: 2, name: "Marketing" },
mfaRequired: true,
},
{
id: 4,
username: 'bob.wilson',
email: 'bob.wilson@example.com',
username: "bob.wilson",
email: "bob.wilson@example.com",
enabled: false,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 604800000,
team: undefined
}
team: undefined,
mfaRequired: false,
},
];
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 }
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
];
setUsers(exampleUsers);
@@ -201,8 +243,8 @@ export default function PeopleSection() {
});
}
} catch (error) {
console.error('[PeopleSection] Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
console.error("[PeopleSection] Failed to fetch people data:", error);
alert({ alertType: "error", title: "Failed to load people data" });
} finally {
setLoading(false);
}
@@ -218,16 +260,17 @@ export default function PeopleSection() {
role: editForm.role,
teamId: editForm.teamId,
});
alert({ alertType: 'success', title: t('workspace.people.editMember.success') });
alert({ alertType: "success", title: t("workspace.people.editMember.success") });
closeEditModal();
fetchData();
await fetchData();
} catch (error: any) {
console.error('[PeopleSection] Failed to update user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.editMember.error');
alert({ alertType: 'error', title: errorMessage });
console.error("[PeopleSection] Failed to update user:", error);
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t("workspace.people.editMember.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
@@ -236,35 +279,61 @@ export default function PeopleSection() {
const handleToggleEnabled = async (user: User) => {
try {
await userManagementService.toggleUserEnabled(user.username, !user.enabled);
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
fetchData();
alert({ alertType: "success", title: t("workspace.people.toggleEnabled.success") });
await fetchData();
} catch (error: any) {
console.error('[PeopleSection] Failed to toggle user status:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.toggleEnabled.error');
alert({ alertType: 'error', title: errorMessage });
console.error("[PeopleSection] Failed to toggle user status:", error);
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t("workspace.people.toggleEnabled.error");
alert({ alertType: "error", title: errorMessage });
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
await userManagementService.deleteUser(user.username);
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
fetchData();
alert({ alertType: "success", title: t("workspace.people.deleteUserSuccess", "User deleted successfully") });
await fetchData();
} catch (error: any) {
console.error('[PeopleSection] Failed to delete user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.deleteUserError', 'Failed to delete user');
alert({ alertType: 'error', title: errorMessage });
console.error("[PeopleSection] Failed to delete user:", error);
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
}
};
const handleDisableMfaByAdmin = async (user: User) => {
try {
if (user.mfaRequired) {
await userManagementService.requireMfaByAdmin(user.username, false);
alert({
alertType: "success",
title: t("workspace.people.mfa.adminDisableSuccess", "MFA disabled successfully for user"),
});
}
await fetchData();
} 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 });
}
};
@@ -291,27 +360,31 @@ export default function PeopleSection() {
setEditUserModalOpened(false);
setSelectedUser(null);
setEditForm({
role: 'ROLE_USER',
role: "ROLE_USER",
teamId: undefined,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredUsers = users.filter((user) => user.username.toLowerCase().includes(searchQuery.toLowerCase()));
const roleOptions = [
{
value: 'ROLE_ADMIN',
label: t('workspace.people.admin'),
description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'),
icon: 'admin-panel-settings'
value: "ROLE_ADMIN",
label: t("workspace.people.admin"),
description: t(
"workspace.people.roleDescriptions.admin",
"Can manage settings and invite members, with full administrative access.",
),
icon: "admin-panel-settings",
},
{
value: 'ROLE_USER',
label: t('workspace.people.member'),
description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'),
icon: 'person'
value: "ROLE_USER",
label: t("workspace.people.member"),
description: t(
"workspace.people.roleDescriptions.member",
"Can view and edit shared files, but cannot manage workspace settings or members.",
),
icon: "person",
},
];
@@ -319,8 +392,10 @@ export default function PeopleSection() {
<Group gap="sm" wrap="nowrap">
<LocalIcon icon={option.icon} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>{option.label}</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
<Text size="sm" fw={500}>
{option.label}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "normal", lineHeight: 1.4 }}>
{option.description}
</Text>
</Box>
@@ -337,7 +412,7 @@ export default function PeopleSection() {
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.people.loading', 'Loading people...')}
{t("workspace.people.loading", "Loading people...")}
</Text>
</Stack>
);
@@ -348,34 +423,40 @@ export default function PeopleSection() {
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.people.title')}
{t("workspace.people.title")}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.people.description')}
{t("workspace.people.description")}
</Text>
</div>
{/* License Information - Compact */}
{licenseInfo && (
<Group gap="md" style={{ fontSize: '0.875rem' }}>
<Group gap="md" style={{ fontSize: "0.875rem" }}>
<Text size="sm" span c="dimmed">
<Text component="span" fw={600} c="inherit">{licenseInfo.totalUsers}</Text>
<Text component="span" c="dimmed"> / </Text>
<Text component="span" fw={600} c="inherit">{licenseInfo.maxAllowedUsers}</Text>
<Text component="span" c="dimmed"> {t('workspace.people.license.users', 'users')}</Text>
<Text component="span" fw={600} c="inherit">
{licenseInfo.totalUsers}
</Text>
<Text component="span" c="dimmed">
{" "}
/{" "}
</Text>
<Text component="span" fw={600} c="inherit">
{licenseInfo.maxAllowedUsers}
</Text>
<Text component="span" c="dimmed">
{" "}
{t("workspace.people.license.users", "users")}
</Text>
</Text>
{licenseInfo.availableSlots === 0 && (
<Group gap="xs" wrap="nowrap" align="center">
<Badge color="red" variant="light" size="sm">
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
{t("workspace.people.license.noSlotsAvailable", "No slots available")}
</Badge>
<Button
size="compact-sm"
variant="outline"
onClick={() => navigate('/settings/adminPlan')}
>
{t('workspace.people.actions.upgrade', 'Upgrade')}
<Button size="compact-sm" variant="outline" onClick={() => navigate("/settings/adminPlan")}>
{t("workspace.people.actions.upgrade", "Upgrade")}
</Button>
</Group>
)}
@@ -384,25 +465,26 @@ export default function PeopleSection() {
<Text size="sm" c="dimmed" span>
<Text component="span" ml={4}>
{t('workspace.people.license.grandfatheredShort', '{{count}} grandfathered', { count: licenseInfo.grandfatheredUserCount })}
{t("workspace.people.license.grandfatheredShort", "{{count}} grandfathered", {
count: licenseInfo.grandfatheredUserCount,
})}
</Text>
</Text>
)}
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
<Badge color="blue" variant="light" size="sm">
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
+{licenseInfo.licenseMaxUsers} {t("workspace.people.license.fromLicense", "from license")}
</Badge>
)}
{/* Enterprise Seat Management Button */}
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
{globalLicenseInfo?.licenseType === "ENTERPRISE" && (
<>
<Text size="sm" c="dimmed" span></Text>
<UpdateSeatsButton
size="xs"
onSuccess={fetchData}
/>
<Text size="sm" c="dimmed" span>
</Text>
<UpdateSeatsButton size="xs" onSuccess={fetchData} />
</>
)}
</Group>
@@ -411,7 +493,7 @@ export default function PeopleSection() {
{/* Header Actions */}
<Group justify="space-between">
<TextInput
placeholder={t('workspace.people.searchMembers')}
placeholder={t("workspace.people.searchMembers")}
leftSection={<LocalIcon icon="search" width="1rem" height="1rem" />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
@@ -428,7 +510,7 @@ export default function PeopleSection() {
onClick={handleAddMembersClick}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
{t("workspace.people.addMembers")}
</Button>
</Tooltip>
</Group>
@@ -438,20 +520,25 @@ export default function PeopleSection() {
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('workspace.people.user')}
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("workspace.people.user")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
{t('workspace.people.role')}
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w={100}>
{t("workspace.people.role")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('workspace.people.team')}
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("workspace.people.team")}
</Table.Th>
<Table.Th w={50}>
{t("workspace.people.mfaTitle", "MFA")}
</Table.Th>
<Table.Th w={50}></Table.Th>
</Table.Tr>
@@ -461,7 +548,7 @@ export default function PeopleSection() {
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.people.noMembersFound')}
{t("workspace.people.noMembersFound")}
</Text>
</Table.Td>
</Table.Tr>
@@ -469,28 +556,28 @@ export default function PeopleSection() {
filteredUsers.map((user) => (
<Table.Tr
key={user.id}
style={isCurrentUser(user) ? { backgroundColor: 'rgba(34, 139, 230, 0.08)' } : undefined}
style={isCurrentUser(user) ? { backgroundColor: "rgba(34, 139, 230, 0.08)" } : undefined}
>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t('workspace.people.disabled', 'Disabled')
? t("workspace.people.disabled", "Disabled")
: user.isActive
? t('workspace.people.activeSession', 'Active session')
: t('workspace.people.active', 'Active')
? t("workspace.people.activeSession", "Active session")
: t("workspace.people.active", "Active")
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Avatar
size={32}
color={user.enabled ? 'blue' : 'gray'}
color={user.enabled ? "blue" : "gray"}
styles={{
root: {
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
border: user.isActive ? "2px solid var(--mantine-color-green-6)" : "none",
opacity: user.enabled ? 1 : 0.5,
}
},
}}
>
{user.username.charAt(0).toUpperCase()}
@@ -505,9 +592,9 @@ export default function PeopleSection() {
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.username}
@@ -522,14 +609,10 @@ export default function PeopleSection() {
</Group>
</Table.Td>
<Table.Td w={100}>
<Badge
size="sm"
variant="light"
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'cyan'}
>
{(user.rolesAsString || '').includes('ROLE_ADMIN')
? t('workspace.people.admin', 'Admin')
: t('workspace.people.member', 'Member')}
<Badge size="sm" variant="light" color={(user.rolesAsString || "").includes("ROLE_ADMIN") ? "blue" : "cyan"}>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin", "Admin")
: t("workspace.people.member", "Member")}
</Badge>
</Table.Td>
<Table.Td>
@@ -539,9 +622,9 @@ export default function PeopleSection() {
size="sm"
maw={150}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.team.name}
@@ -551,36 +634,62 @@ export default function PeopleSection() {
<Text size="sm"></Text>
)}
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
<Text size="xs">
Last Activity: {user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
:t('never', 'Never')}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle"size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
<Table.Td>
{!user.mfaEnabled && user.mfaRequired ? (
// shield icon when MFA is required
<Tooltip label={t("workspace.people.mfa.required", "MFA Required")} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<LocalIcon icon="shield-lock" width="1rem" height="1rem" />
</Tooltip>
) : null}
{!user.mfaEnabled && !user.mfaRequired ? (
// dash when MFA is not required
<Tooltip
label={t("workspace.people.mfa.notRequired", "MFA Not Required")}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<LocalIcon icon="shield-question-rounded" width="1rem" height="1rem" />
</Tooltip>
) : null}
{user.mfaEnabled && (
// key icon when MFA is enabled
<Tooltip label={t("workspace.people.mfa.enabled", "MFA Enabled")} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<LocalIcon icon="key" width="1rem" height="1rem" />
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>
Authentication: {user.authenticationType || "Unknown"}
</Text>
<Text size="xs">
Last Activity:{" "}
{user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
: t("never", "Never")}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
{!isCurrentUser(user) && (
{/* Actions menu */}
{!isCurrentUser(user) && (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
@@ -591,25 +700,31 @@ export default function PeopleSection() {
onClick={() => openEditModal(user)}
disabled={!loginEnabled}
>
{t('workspace.people.editRole', 'Edit Role & Team')}
{t("workspace.people.editRole", "Edit Role & Team")}
</Menu.Item>
)}
{!isCurrentUser(user) && (
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)}
disabled={!loginEnabled}
>
{t('workspace.people.changePassword.action', 'Change password')}
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)}
disabled={!loginEnabled}
>
{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" />}
leftSection={
user.enabled ? (
<LocalIcon icon="person-off" width="1rem" height="1rem" />
) : (
<LocalIcon icon="person-check" width="1rem" height="1rem" />
)
}
onClick={() => handleToggleEnabled(user)}
disabled={!loginEnabled}
>
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
{user.enabled ? t("workspace.people.disable") : t("workspace.people.enable")}
</Menu.Item>
)}
{!isCurrentUser(user) && user.mfaEnabled && (
@@ -618,50 +733,66 @@ export default function PeopleSection() {
<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 });
}
}}
onClick={async () => handleDisableMfaByAdmin(user)}
disabled={!loginEnabled}
>
{t('workspace.people.mfa.disableByAdmin', 'Disable MFA')}
{t("workspace.people.mfa.disableByAdmin", "Disable MFA")}
</Menu.Item>
</>
)}
{!isCurrentUser(user) && !user.mfaEnabled && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="key" width="1rem" height="1rem" />}
onClick={() => handleClickButton(user)}
disabled={!loginEnabled}
>
{user.mfaRequired
? t("workspace.people.mfa.setOptional", "Set MFA Optional")
: t("workspace.people.mfa.setRequired", "Set MFA Require")}
</Menu.Item>
</>
)}
{!isCurrentUser(user) && (
<>
<Menu.Divider />
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)} disabled={!loginEnabled}>
{t('workspace.people.deleteUser')}
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteUser(user)}
disabled={!loginEnabled}
>
{t("workspace.people.deleteUser")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Table.Td>
</Table.Tr>
))
)}
)}
{/* link to Account */}
{isCurrentUser(user) && (
<Tooltip
label={t("workspace.people.viewProfile", "View your profile")}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ActionIcon variant="subtle" onClick={() => navigate("/settings/account")} disabled={!loginEnabled}>
<LocalIcon icon="account-circle" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{/* Invite Members Modal (reusable) */}
<InviteMembersModal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
onSuccess={fetchData}
/>
<InviteMembersModal opened={inviteModalOpened} onClose={() => setInviteModalOpened(false)} onSuccess={fetchData} />
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
@@ -686,34 +817,34 @@ export default function PeopleSection() {
onClick={closeEditModal}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.editMember.title')}
{t("workspace.people.editMember.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.people.editMember.editing')} <strong>{selectedUser?.username}</strong>
{t("workspace.people.editMember.editing")} <strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t('workspace.people.editMember.role')}
label={t("workspace.people.editMember.role")}
data={roleOptions}
value={editForm.role}
onChange={(value) => setEditForm({ ...editForm, role: value || 'ROLE_USER' })}
onChange={(value) => setEditForm({ ...editForm, role: value || "ROLE_USER" })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.editMember.team')}
placeholder={t('workspace.people.editMember.teamPlaceholder')}
label={t("workspace.people.editMember.team")}
placeholder={t("workspace.people.editMember.teamPlaceholder")}
data={teamOptions}
value={editForm.teamId?.toString()}
onChange={(value) => setEditForm({ ...editForm, teamId: value ? parseInt(value) : undefined })}
@@ -721,7 +852,7 @@ export default function PeopleSection() {
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
{t('workspace.people.editMember.submit')}
{t("workspace.people.editMember.submit")}
</Button>
</Stack>
</Box>
@@ -1,6 +1,7 @@
import apiClient from '@app/services/apiClient';
export interface User {
mfaRequired: boolean;
id: number;
username: string;
email?: string;
@@ -40,6 +41,7 @@ export interface AdminSettingsData {
premiumEnabled: boolean;
mailEnabled: boolean;
userSettings?: Record<string, any>;
mfaRequired: boolean;
}
export interface CreateUserRequest {
@@ -305,4 +307,15 @@ export const userManagementService = {
await apiClient.post(`/api/v1/auth/mfa/disable/admin/${encodeURIComponent(username)}`, undefined);
},
/**
* Require MFA for a user (admin only)
*/
async requireMfaByAdmin(username: string, active: boolean): Promise<void> {
if (!active) {
await apiClient.post(`/api/v1/auth/mfa/optional/admin/${encodeURIComponent(username)}`, undefined);
return;
}
await apiClient.post(`/api/v1/auth/mfa/require/admin/${encodeURIComponent(username)}`, undefined);
},
};