mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
15
Commits
v2.7.0
...
mfa_20260129
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9908c3be | ||
|
|
fe60c94bef | ||
|
|
6758723256 | ||
|
|
2377fed045 | ||
|
|
139d1abd02 | ||
|
|
c17a6805b2 | ||
|
|
397493ca4d | ||
|
|
273f6213a2 | ||
|
|
2025418e16 | ||
|
|
0fd1898704 | ||
|
|
ffe93ed5a8 | ||
|
|
119ffbbf2f | ||
|
|
cd00dea6f1 | ||
|
|
15e4785de8 | ||
|
|
5dcaf5306a |
@@ -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
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+12
-2
@@ -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);
|
||||
}
|
||||
|
||||
+66
@@ -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
|
||||
*
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
+7
@@ -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);
|
||||
|
||||
+29
-13
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+20
@@ -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))
|
||||
|
||||
+27
-5
@@ -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."
|
||||
|
||||
+45
@@ -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>
|
||||
|
||||
|
||||
+352
-229
@@ -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);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user