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:
+359
-228
@@ -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);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user