Compare commits

...
Author SHA1 Message Date
Ludy 7e9908c3be Merge branch 'main' into mfa_20260129 2026-02-20 09:30:11 +01:00
Ludy fe60c94bef Merge branch 'main' into mfa_20260129 2026-02-12 18:43:19 +01:00
Ludy 6758723256 Update greeting from 'Hello' to 'Goodbye' 2026-02-12 18:26:05 +01:00
Ludy 2377fed045 Merge branch 'main' into mfa_20260129 2026-02-07 18:47:10 +01:00
Ludy 139d1abd02 Merge branch 'main' into mfa_20260129 2026-02-01 09:51:18 +01:00
Ludy c17a6805b2 Merge branch 'main' into mfa_20260129 2026-01-31 20:57:13 +01:00
Ludy87 397493ca4d Add MFA enforcement and related labels to en-GB locale
Introduces new translation keys for multi-factor authentication enforcement for admins and users, as well as additional MFA-related status and action labels. These changes support upcoming MFA features in the UI.
2026-01-30 22:11:05 +01:00
Ludy87 273f6213a2 Refactor MFA enforcement property access formatting
Improves readability by reformatting chained method calls for MFA enforcement property access in InitialSecuritySetup and UserController. No functional changes were made.
2026-01-30 21:07:17 +01:00
Ludy87 2025418e16 Merge branch 'mfa_20260129' of https://github.com/Stirling-Tools/Stirling-PDF into mfa_20260129 2026-01-30 20:56:56 +01:00
Ludy87 0fd1898704 Add configurable MFA enforcement for admins and users
Introduces new security settings to enforce multi-factor authentication (MFA) for admin and/or non-admin users, configurable via backend properties and frontend UI. Updates user creation and management logic to respect these settings, and enhances the admin interface to allow toggling MFA enforcement.
2026-01-30 20:55:29 +01:00
Ludy ffe93ed5a8 Merge branch 'main' into mfa_20260129 2026-01-30 17:28:59 +01:00
Ludy87 119ffbbf2f 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.
2026-01-29 18:51:26 +01:00
Ludy cd00dea6f1 Merge branch 'main' into mfa_20260129 2026-01-29 18:18:09 +01:00
Ludy87 15e4785de8 Update MfaServiceTest.java 2026-01-29 17:45:52 +01:00
Ludy87 5dcaf5306a Add admin endpoints and locking for MFA management
Introduces admin endpoints to require or make MFA optional for users, and adds pessimistic locking to user settings updates for MFA operations. Improves consistency in MFA state changes, updates related service and repository methods, and enhances test coverage for MFA setup logic.
2026-01-29 17:38:43 +01:00
15 changed files with 619 additions and 253 deletions
@@ -214,6 +214,7 @@ public class ApplicationProperties {
private Jwt jwt = new Jwt();
private Validation validation = new Validation();
private String xFrameOptions = "DENY";
private MFARequired mfaRequired = new MFARequired();
public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled();
@@ -531,6 +532,12 @@ public class ApplicationProperties {
private boolean hardFail = false;
}
}
@Data
public static class MFARequired {
private boolean enforceForAdmins = false;
private boolean enforceForUsers = false;
}
}
@Data
@@ -85,6 +85,9 @@ security:
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
mfaRequired:
enforceForAdmins: false # Set to 'true' to enforce MFA for admin users only - excludes normal users; saml2 and oauth2 users are not affected
enforceForUsers: false # Set to 'true' to enforce MFA for users only - excludes admin users; saml2 and oauth2 users are not affected
premium:
key: 00000000-0000-0000-0000-000000000000
@@ -316,6 +316,9 @@ public class ProprietaryUIDataController {
if (settingsCopy.containsKey("mfaSecret")) {
settingsCopy.put("mfaSecret", "********");
}
if (settingsCopy.containsKey("mfaRequired")) {
settingsCopy.put("mfaRequired", settingsCopy.get("mfaRequired"));
}
userSettings.put(username, settingsCopy);
userSessions.put(username, hasActiveSession);
userLastRequest.put(username, lastRequest);
@@ -120,7 +120,12 @@ public class InitialSecuritySetup {
.password(initialPassword)
.team(team)
.role(Role.ADMIN.getRoleId())
.firstLogin(false);
.firstLogin(false)
.requireMfa(
applicationProperties
.getSecurity()
.getMfaRequired()
.isEnforceForAdmins());
userService.saveUserCore(builder.build());
log.info("Admin user created: {}", initialUsername);
} else {
@@ -140,7 +145,12 @@ public class InitialSecuritySetup {
.password(defaultPassword)
.team(team)
.role(Role.ADMIN.getRoleId())
.firstLogin(true);
.firstLogin(true)
.requireMfa(
applicationProperties
.getSecurity()
.getMfaRequired()
.isEnforceForAdmins());
userService.saveUserCore(builder.build());
log.info("Default admin user created: {}", defaultUsername);
}
@@ -611,6 +611,72 @@ public class AuthController {
}
}
/**
* Admin endpoint to require MFA for a user
*
* @param username Username of the user to require MFA for
* @return Response indicating success or failure
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/mfa/require/admin/{username}")
public ResponseEntity<?> requireMfaByAdmin(@PathVariable String username) {
try {
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
mfaService.setMfaRequired(user, true);
return ResponseEntity.ok(Map.of("required", true));
} catch (UsernameNotFoundException e) {
log.warn("User not found for MFA enable: {}", username);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found"));
} catch (Exception e) {
log.error("Failed to enable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to enable MFA"));
}
}
/**
* Admin endpoint to set MFA as optional for a user
*
* @param username Username of the user to set MFA as optional for
* @return Response indicating success or failure
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/mfa/optional/admin/{username}")
public ResponseEntity<?> optionalMfaByAdmin(@PathVariable String username) {
try {
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
mfaService.setMfaRequired(user, false);
return ResponseEntity.ok(Map.of("required", false));
} catch (UsernameNotFoundException e) {
log.warn("User not found for MFA enable: {}", username);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found"));
} catch (Exception e) {
log.error("Failed to enable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to enable MFA"));
}
}
/**
* Helper method to build user response object
*
@@ -110,7 +110,12 @@ public class UserController {
.username(username)
.password(password)
.team(team)
.enabled(false);
.enabled(false)
.requireMfa(
applicationProperties
.getSecurity()
.getMfaRequired()
.isEnforceForUsers());
User user = userService.saveUserCore(builder.build());
log.info("User registered successfully: {}", username);
@@ -458,6 +463,10 @@ public class UserController {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password must be at least 6 characters."));
}
forceMFA =
applicationProperties.getSecurity().getMfaRequired().isEnforceForUsers()
? true
: forceMFA;
builder.password(password).firstLogin(forceChange).requireMfa(forceMFA);
}
userService.saveUserCore(builder.build());
@@ -892,7 +901,12 @@ public class UserController {
.password(temporaryPassword)
.teamId(teamId)
.role(role)
.firstLogin(true);
.firstLogin(true)
.requireMfa(
applicationProperties
.getSecurity()
.getMfaRequired()
.isEnforceForUsers());
userService.saveUserCore(builder.build());
// Send invite email
@@ -4,11 +4,14 @@ import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
@@ -22,6 +25,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
Optional<User> findByIdWithSettings(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
Optional<User> findByIdWithSettingsForUpdate(@Param("id") Long id);
Optional<User> findByUsername(String username);
Optional<User> findByApiKey(String apiKey);
@@ -66,14 +66,12 @@ public class MfaService {
@Transactional
public void setSecret(User user, String secret)
throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
User managedUser = getUserWithSettings(user, true);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "false");
// Clear existing values and flush the removals before inserting a new secret. This keeps
// the (user_id, setting_key) PK satisfied even when the persistence context re-inserts the
// same keys within a single transaction.
settings.remove(MFA_SECRET_KEY);
settings.remove(MFA_LAST_USED_STEP_KEY);
if (managedUser != null && managedUser.getId() != null) {
userRepository.deleteSettingsByUserIdAndKeys(
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
@@ -90,8 +88,9 @@ public class MfaService {
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
@Transactional
public void enableMfa(User user) throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
User managedUser = getUserWithSettings(user, true);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "true");
persist(managedUser);
@@ -104,12 +103,17 @@ public class MfaService {
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
@Transactional
public void clearPendingSecret(User user) throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
User managedUser = getUserWithSettings(user, true);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "false");
settings.remove(MFA_SECRET_KEY);
settings.remove(MFA_LAST_USED_STEP_KEY);
if (managedUser != null && managedUser.getId() != null) {
userRepository.deleteSettingsByUserIdAndKeys(
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
userRepository.flush();
}
persist(managedUser);
}
@@ -120,12 +124,16 @@ public class MfaService {
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
@Transactional
public void disableMfa(User user) throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
User managedUser = getUserWithSettings(user, true);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "false");
settings.remove(MFA_SECRET_KEY);
settings.remove(MFA_LAST_USED_STEP_KEY);
if (managedUser != null && managedUser.getId() != null) {
userRepository.deleteSettingsByUserIdAndKeys(
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
userRepository.flush();
}
persist(managedUser);
}
@@ -163,9 +171,10 @@ public class MfaService {
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
@Transactional
public boolean markTotpStepUsed(User user, long timeStep)
throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
User managedUser = getUserWithSettings(user, true);
Map<String, String> settings = ensureSettings(managedUser);
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
if (lastUsed != null) {
@@ -206,9 +215,10 @@ public class MfaService {
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
@Transactional
public void setMfaRequired(User user, boolean required)
throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
User managedUser = getUserWithSettings(user, true);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_REQUIRED_KEY, Boolean.toString(required));
log.info("Set MFA required={} for user {}", required, managedUser.getUsername());
@@ -225,10 +235,16 @@ public class MfaService {
}
private User getUserWithSettings(User user) {
return getUserWithSettings(user, false);
}
private User getUserWithSettings(User user, boolean lockForUpdate) {
if (user == null || user.getId() == null) {
return user;
}
return userRepository.findByIdWithSettings(user.getId()).orElse(user);
return lockForUpdate
? userRepository.findByIdWithSettingsForUpdate(user.getId()).orElse(user)
: userRepository.findByIdWithSettings(user.getId()).orElse(user);
}
private Map<String, String> ensureSettings(User user) {
@@ -393,12 +393,12 @@ public class UserService implements UserServiceInterface {
settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled()));
if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) {
settings.put(MFA_SECRET_KEY, request.getMfaSecret());
} else {
} else if (settings.containsKey(MFA_SECRET_KEY)) {
settings.remove(MFA_SECRET_KEY);
}
if (request.getMfaLastUsedStep() != null) {
settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep()));
} else {
} else if (settings.containsKey(MFA_LAST_USED_STEP_KEY)) {
settings.remove(MFA_LAST_USED_STEP_KEY);
}
log.info(
@@ -1,5 +1,6 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -89,6 +90,25 @@ class AuthControllerMfaTest {
verify(mfaService).setSecret(user, "SECRET");
}
@Test
void setupMfaAlwaysGeneratesNewSecret() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(false);
when(totpService.generateSecret()).thenReturn("SECRET");
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isOk())
.andExpect(jsonPath("$.secret").value("SECRET"))
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
verify(mfaService).setSecret(user, "SECRET");
verify(mfaService, never()).getSecret(any());
verify(totpService).generateSecret();
verify(totpService).buildOtpAuthUri(USERNAME, "SECRET");
}
@Test
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
@@ -1,8 +1,12 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -23,14 +27,20 @@ class MfaServiceTest {
@Test
void setSecretStoresSecretAndDisablesMfa() throws Exception {
User user = new User();
user.setId(1L);
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "10");
when(userRepository.findByIdWithSettingsForUpdate(1L)).thenReturn(Optional.of(user));
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.setSecret(user, "NEWSECRET");
assertEquals("NEWSECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
assertEquals("10", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(userRepository)
.deleteSettingsByUserIdAndKeys(
eq(1L),
eq(List.of(MfaService.MFA_SECRET_KEY, MfaService.MFA_LAST_USED_STEP_KEY)));
verify(databaseService).exportDatabase();
}
@@ -48,15 +58,21 @@ class MfaServiceTest {
@Test
void disableMfaClearsSecretAndUsage() throws Exception {
User user = new User();
user.setId(1L);
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "20");
when(userRepository.findByIdWithSettingsForUpdate(1L)).thenReturn(Optional.of(user));
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.disableMfa(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
assertEquals("SECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertEquals("20", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(userRepository)
.deleteSettingsByUserIdAndKeys(
eq(1L),
eq(List.of(MfaService.MFA_SECRET_KEY, MfaService.MFA_LAST_USED_STEP_KEY)));
verify(databaseService).exportDatabase();
}
@@ -122,15 +138,21 @@ class MfaServiceTest {
@Test
void clearPendingSecretResetsValues() throws Exception {
User user = new User();
user.setId(1L);
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "12");
when(userRepository.findByIdWithSettingsForUpdate(1L)).thenReturn(Optional.of(user));
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.clearPendingSecret(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
assertEquals("SECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertEquals("12", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(userRepository)
.deleteSettingsByUserIdAndKeys(
eq(1L),
eq(List.of(MfaService.MFA_SECRET_KEY, MfaService.MFA_LAST_USED_STEP_KEY)));
verify(databaseService).exportDatabase();
}
@@ -1276,6 +1276,14 @@ saml2 = "SAML2 Only"
description = "Time before failed login attempts are reset"
label = "Login Reset Time (minutes)"
[admin.settings.security.mfaEnforcement.enforceForAdmins]
description = "Require multi-factor authentication for all admin users"
label = "Enforce Multi-Factor Authentication for Admins"
[admin.settings.security.mfaEnforcement.enforceForUsers]
description = "Require multi-factor authentication for all non-admin users"
label = "Enforce Multi-Factor Authentication for Users"
[admin.settings.security.ssoNotice]
message = "OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management."
title = "Looking for SSO/SAML settings?"
@@ -6695,6 +6703,7 @@ enable = "Enable"
loading = "Loading people..."
loginRequired = "Enable login mode first"
member = "Member"
mfaTitle = "MFA"
noMembersFound = "No members found"
role = "Role"
searchMembers = "Search members..."
@@ -6702,6 +6711,7 @@ status = "Status"
team = "Team"
title = "People"
user = "User"
viewProfile = "View your profile"
[workspace.people.actions]
label = "Actions"
@@ -6838,7 +6848,14 @@ users = "users"
[workspace.people.mfa]
adminDisableError = "Failed to disable MFA for user"
adminDisableSuccess = "MFA disabled successfully for user"
adminEnableError = "Failed to enable MFA for user"
adminEnableSuccess = "MFA enabled successfully for user"
disableByAdmin = "Disable MFA"
enabled = "MFA Enabled"
notRequired = "MFA Not Required"
required = "MFA Required"
setOptional = "Set MFA Optional"
setRequired = "Set MFA Require"
[workspace.people.roleDescriptions]
admin = "Can manage settings and invite members, with full administrative access."
@@ -17,6 +17,10 @@ interface SecuritySettingsData {
loginAttemptCount?: number;
loginResetTimeMinutes?: number;
xFrameOptions?: string;
mfaRequired?: {
enforceForAdmins?: boolean;
enforceForUsers?: boolean;
}
jwt?: {
persistence?: boolean;
enableKeyRotation?: boolean;
@@ -130,6 +134,9 @@ export default function AdminSecuritySection() {
'security.loginAttemptCount': securitySettings.loginAttemptCount,
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
'security.xFrameOptions': securitySettings.xFrameOptions,
// MFA settings
'security.mfaRequired.enforceForAdmins': securitySettings.mfaRequired?.enforceForAdmins,
'security.mfaRequired.enforceForUsers': securitySettings.mfaRequired?.enforceForUsers,
// JWT settings
'security.jwt.persistence': securitySettings.jwt?.persistence,
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
@@ -314,6 +321,44 @@ export default function AdminSecuritySection() {
disabled={!loginEnabled}
/>
</div>
{/* MFA Enforcement Settings */}
<div>
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.mfaEnforcement.enforceForAdmins.label', 'Enforce Multi-Factor Authentication for Admins')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.mfaEnforcement.enforceForAdmins.description', 'Require multi-factor authentication for all admin users')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.mfaRequired?.enforceForAdmins || false}
onChange={(e) => setSettings({ ...settings, mfaRequired: { ...settings?.mfaRequired, enforceForAdmins: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('mfaRequired.enforceForAdmins')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.mfaEnforcement.enforceForUsers.label', 'Enforce Multi-Factor Authentication for Users')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.mfaEnforcement.enforceForUsers.description', 'Require multi-factor authentication for all non-admin users')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.mfaRequired?.enforceForUsers || false}
onChange={(e) => setSettings({ ...settings, mfaRequired: { ...settings?.mfaRequired, enforceForUsers: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('mfaRequired.enforceForUsers')} />
</Group>
</div>
</Stack>
</div>
</Stack>
</Paper>
@@ -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,82 @@ 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 handleSetMfaButton = async (user: User) => {
try {
if (!user.mfaRequired) {
await userManagementService.requireMfaByAdmin(user.username, true);
console.log("[PeopleSection] set require maf to mandatory");
alert({
alertType: "success",
title: t("workspace.people.mfa.adminEnableSuccess", "MFA enabled successfully for user"),
});
} else {
await userManagementService.requireMfaByAdmin(user.username, false);
console.log("[PeopleSection] set require maf to optional");
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();
};
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 +163,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 +235,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 +252,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 +271,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 +352,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 +384,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 +404,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 +415,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 +457,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 +485,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 +502,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 +512,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 +540,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 +548,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 +584,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 +601,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 +614,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 +626,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 +692,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" />}
onClick={() => handleToggleEnabled(user)}
leftSection={
user.enabled ? (
<LocalIcon icon="person-off" width="1rem" height="1rem" />
) : (
<LocalIcon icon="person-check" width="1rem" height="1rem" />
)
}
onClick={async () => 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 +725,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={async () => handleSetMfaButton(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={async () => 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 +809,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 +844,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);
},
};