mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-16 23:08:38 +02:00
Unlock account (#5984)
This commit is contained in:
@@ -47,6 +47,7 @@ import stirling.software.proprietary.security.model.dto.AdminUserSummary;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@@ -71,6 +72,7 @@ public class ProprietaryUIDataController {
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final MfaService mfaService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -84,7 +86,8 @@ public class ProprietaryUIDataController {
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
UserLicenseSettingsService licenseSettingsService,
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
MfaService mfaService) {
|
||||
MfaService mfaService,
|
||||
LoginAttemptService loginAttemptService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
@@ -97,6 +100,7 @@ public class ProprietaryUIDataController {
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
this.auditRepository = auditRepository;
|
||||
this.mfaService = mfaService;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,6 +391,7 @@ public class ProprietaryUIDataController {
|
||||
data.setPremiumEnabled(premiumEnabled);
|
||||
data.setMailEnabled(applicationProperties.getMail().isEnabled());
|
||||
data.setUserSettings(userSettings);
|
||||
data.setLockedUsers(loginAttemptService.getAllBlockedUsers());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -605,6 +610,7 @@ public class ProprietaryUIDataController {
|
||||
private boolean premiumEnabled;
|
||||
private boolean mailEnabled;
|
||||
private Map<String, Map<String, String>> userSettings;
|
||||
private List<String> lockedUsers;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -48,6 +48,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -67,6 +68,7 @@ public class UserController {
|
||||
private final UserRepository userRepository;
|
||||
private final Optional<EmailService> emailService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/register")
|
||||
@@ -775,6 +777,14 @@ public class UserController {
|
||||
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/unlockUser/{username}")
|
||||
@Audited(type = AuditEventType.SETTINGS_CHANGED, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<?> unlockUser(@PathVariable("username") String username) {
|
||||
loginAttemptService.resetAttempts(username);
|
||||
return ResponseEntity.ok(Map.of("message", "User account unlocked successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/deleteUser/{username}")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -79,6 +82,28 @@ public class LoginAttemptService {
|
||||
return attemptCounter.getAttemptCount() >= MAX_ATTEMPT;
|
||||
}
|
||||
|
||||
public void resetAttempts(String key) {
|
||||
if (key == null || key.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String normalizedKey = key.toLowerCase(Locale.ROOT);
|
||||
attemptsCache.remove(normalizedKey);
|
||||
}
|
||||
|
||||
public boolean isBlockingEnabled() {
|
||||
return isBlockedEnabled;
|
||||
}
|
||||
|
||||
public List<String> getAllBlockedUsers() {
|
||||
if (!isBlockedEnabled) {
|
||||
return List.of();
|
||||
}
|
||||
return attemptsCache.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().getAttemptCount() >= MAX_ATTEMPT)
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public int getRemainingAttempts(String key) {
|
||||
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
|
||||
// Arbitrarily high number if tracking is disabled
|
||||
|
||||
@@ -29,6 +29,7 @@ import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -47,6 +48,7 @@ class ProprietaryUIDataControllerTest {
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
@Mock private PersistentAuditEventRepository auditRepository;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AuditConfigurationProperties auditConfig;
|
||||
@@ -79,7 +81,8 @@ class ProprietaryUIDataControllerTest {
|
||||
false,
|
||||
licenseSettingsService,
|
||||
auditRepository,
|
||||
mfaService);
|
||||
mfaService,
|
||||
loginAttemptService);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -28,6 +28,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@@ -47,6 +48,7 @@ class UserControllerTest {
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private EmailService emailService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
@@ -65,7 +67,8 @@ class UserControllerTest {
|
||||
teamRepository,
|
||||
userRepository,
|
||||
Optional.of(emailService),
|
||||
licenseSettingsService);
|
||||
licenseSettingsService,
|
||||
loginAttemptService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@@ -138,4 +141,13 @@ class UserControllerTest {
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error").value("User not found."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlockUserCallsResetAttemptsAndReturnsOk() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/user/admin/unlockUser/lockeduser"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("User account unlocked successfully"));
|
||||
|
||||
verify(loginAttemptService).resetAttempts("lockeduser");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@@ -236,4 +237,145 @@ class LoginAttemptServiceTest {
|
||||
// If you later clamp to 0, update this assertion accordingly and add a new test.
|
||||
assertEquals(expected, actual, "Current behavior returns negative values without clamping");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resetAttempts(): removes entry from cache for given key")
|
||||
void resetAttempts_shouldRemoveEntryFromCache() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
AttemptCounter counter = new AttemptCounter();
|
||||
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
|
||||
ac.setAccessible(true);
|
||||
ac.setInt(counter, 5);
|
||||
attemptsCache.put("blockeduser", counter);
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
var method = svc.getClass().getMethod("resetAttempts", String.class);
|
||||
method.invoke(svc, "BlockedUser"); // case-insensitive
|
||||
|
||||
assertFalse(
|
||||
attemptsCache.containsKey("blockeduser"),
|
||||
"resetAttempts should remove the user's entry from the cache");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resetAttempts(): does nothing for null or blank key")
|
||||
void resetAttempts_shouldDoNothingForNullOrBlankKey() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
attemptsCache.put("existing", new AttemptCounter());
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
var method = svc.getClass().getMethod("resetAttempts", String.class);
|
||||
method.invoke(svc, (Object) null);
|
||||
method.invoke(svc, " ");
|
||||
|
||||
assertEquals(1, attemptsCache.size(), "Null or blank key should not modify the cache");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isBlockingEnabled(): returns true when blocking is enabled")
|
||||
void isBlockingEnabled_shouldReturnTrueWhenEnabled() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
|
||||
var method = svc.getClass().getMethod("isBlockingEnabled");
|
||||
boolean result = (Boolean) method.invoke(svc);
|
||||
|
||||
assertTrue(result, "isBlockingEnabled should return true when isBlockedEnabled is true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isBlockingEnabled(): returns false when blocking is disabled")
|
||||
void isBlockingEnabled_shouldReturnFalseWhenDisabled() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", false);
|
||||
|
||||
var method = svc.getClass().getMethod("isBlockingEnabled");
|
||||
boolean result = (Boolean) method.invoke(svc);
|
||||
|
||||
assertFalse(result, "isBlockingEnabled should return false when isBlockedEnabled is false");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getAllBlockedUsers(): returns empty list when blocking is disabled")
|
||||
void getAllBlockedUsers_shouldReturnEmptyWhenDisabled() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", false);
|
||||
setPrivate(svc, "attemptsCache", new ConcurrentHashMap<String, AttemptCounter>());
|
||||
|
||||
var method = svc.getClass().getMethod("getAllBlockedUsers");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> result = (List<String>) method.invoke(svc);
|
||||
|
||||
assertTrue(
|
||||
result.isEmpty(),
|
||||
"getAllBlockedUsers should return empty list when blocking is disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getAllBlockedUsers(): returns only users at or above MAX_ATTEMPT")
|
||||
void getAllBlockedUsers_shouldReturnOnlyBlockedUsers() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
setPrivate(svc, "MAX_ATTEMPT", 3);
|
||||
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
|
||||
ac.setAccessible(true);
|
||||
|
||||
// User with exactly MAX_ATTEMPT attempts (blocked)
|
||||
AttemptCounter blocked1 = new AttemptCounter();
|
||||
ac.setInt(blocked1, 3);
|
||||
attemptsCache.put("blocked1", blocked1);
|
||||
|
||||
// User with more than MAX_ATTEMPT attempts (blocked)
|
||||
AttemptCounter blocked2 = new AttemptCounter();
|
||||
ac.setInt(blocked2, 5);
|
||||
attemptsCache.put("blocked2", blocked2);
|
||||
|
||||
// User with fewer than MAX_ATTEMPT attempts (not blocked)
|
||||
AttemptCounter notBlocked = new AttemptCounter();
|
||||
ac.setInt(notBlocked, 2);
|
||||
attemptsCache.put("safe", notBlocked);
|
||||
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
var method = svc.getClass().getMethod("getAllBlockedUsers");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> result = (List<String>) method.invoke(svc);
|
||||
|
||||
assertEquals(2, result.size(), "Should return exactly 2 blocked users");
|
||||
assertTrue(result.contains("blocked1"), "Should contain blocked1");
|
||||
assertTrue(result.contains("blocked2"), "Should contain blocked2");
|
||||
assertFalse(result.contains("safe"), "Should not contain safe user");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getAllBlockedUsers(): returns empty list when no users are blocked")
|
||||
void getAllBlockedUsers_shouldReturnEmptyWhenNoUsersBlocked() throws Exception {
|
||||
Object svc = constructLoginAttemptService();
|
||||
setPrivateBoolean(svc, "isBlockedEnabled", true);
|
||||
setPrivate(svc, "MAX_ATTEMPT", 3);
|
||||
|
||||
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
|
||||
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
|
||||
ac.setAccessible(true);
|
||||
|
||||
AttemptCounter notBlocked = new AttemptCounter();
|
||||
ac.setInt(notBlocked, 1);
|
||||
attemptsCache.put("user1", notBlocked);
|
||||
|
||||
setPrivate(svc, "attemptsCache", attemptsCache);
|
||||
|
||||
var method = svc.getClass().getMethod("getAllBlockedUsers");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> result = (List<String>) method.invoke(svc);
|
||||
|
||||
assertTrue(result.isEmpty(), "Should return empty list when no users exceed MAX_ATTEMPT");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7978,6 +7978,7 @@ activeSession = "Active session"
|
||||
addMembers = "Add Members"
|
||||
admin = "Admin"
|
||||
confirmDelete = "Are you sure you want to delete this user? This action cannot be undone."
|
||||
confirmUnlock = "Are you sure you want to unlock this user account?"
|
||||
deleteUser = "Delete User"
|
||||
deleteUserError = "Failed to delete user"
|
||||
deleteUserSuccess = "User deleted successfully"
|
||||
@@ -7986,6 +7987,8 @@ disable = "Disable"
|
||||
disabled = "Disabled"
|
||||
editRole = "Edit Role"
|
||||
enable = "Enable"
|
||||
locked = "locked"
|
||||
lockedBadge = "Locked"
|
||||
loading = "Loading people..."
|
||||
loginRequired = "Enable login mode first"
|
||||
member = "Member"
|
||||
@@ -7995,6 +7998,9 @@ searchMembers = "Search members..."
|
||||
status = "Status"
|
||||
team = "Team"
|
||||
title = "People"
|
||||
unlockAccount = "Unlock Account"
|
||||
unlockUserError = "Failed to unlock user account"
|
||||
unlockUserSuccess = "User account unlocked successfully"
|
||||
user = "User"
|
||||
|
||||
[workspace.people.actions]
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function PeopleSection() {
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [mailEnabled, setMailEnabled] = useState(false);
|
||||
const [lockedUsers, setLockedUsers] = useState<string[]>([]);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
@@ -80,6 +81,7 @@ export default function PeopleSection() {
|
||||
: null;
|
||||
|
||||
const isCurrentUser = (user: User) => currentUser?.username === user.username;
|
||||
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
|
||||
|
||||
// Form state for edit user modal
|
||||
const [editForm, setEditForm] = useState({
|
||||
@@ -128,6 +130,7 @@ export default function PeopleSection() {
|
||||
totalUsers: adminData.totalUsers,
|
||||
});
|
||||
setMailEnabled(adminData.mailEnabled);
|
||||
setLockedUsers(adminData.lockedUsers || []);
|
||||
} else {
|
||||
// Provide example data when login is disabled
|
||||
const exampleUsers: User[] = [
|
||||
@@ -189,6 +192,7 @@ export default function PeopleSection() {
|
||||
setUsers(exampleUsers);
|
||||
setTeams(exampleTeams);
|
||||
setMailEnabled(false);
|
||||
setLockedUsers([]);
|
||||
|
||||
// Example license information
|
||||
setLicenseInfo({
|
||||
@@ -268,6 +272,26 @@ export default function PeopleSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockUser = async (user: User) => {
|
||||
const confirmMessage = t('workspace.people.confirmUnlock', 'Are you sure you want to unlock this user account?');
|
||||
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userManagementService.unlockUser(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.unlockUserSuccess', 'User account unlocked successfully') });
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('[PeopleSection] Failed to unlock user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.unlockUserError', 'Failed to unlock user account');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setEditForm({
|
||||
@@ -389,6 +413,12 @@ export default function PeopleSection() {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{lockedUsers.length > 0 && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{lockedUsers.length} {t('workspace.people.locked', 'locked')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
|
||||
@@ -497,22 +527,29 @@ export default function PeopleSection() {
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{isLockedUser(user) && (
|
||||
<Badge color="orange" variant="light" size="xs">
|
||||
{t('workspace.people.lockedBadge', 'Locked')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
@@ -612,6 +649,15 @@ export default function PeopleSection() {
|
||||
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && isLockedUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock-open" width="1rem" height="1rem" />}
|
||||
onClick={() => handleUnlockUser(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.unlockAccount', 'Unlock Account')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && user.mfaEnabled && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -51,6 +51,9 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
availableSlots: number;
|
||||
} | null>(null);
|
||||
const [mailEnabled, setMailEnabled] = useState(false);
|
||||
const [lockedUsers, setLockedUsers] = useState<string[]>([]);
|
||||
|
||||
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamDetails();
|
||||
@@ -75,6 +78,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
availableSlots: adminData.availableSlots,
|
||||
});
|
||||
setMailEnabled(adminData.mailEnabled);
|
||||
setLockedUsers(adminData.lockedUsers || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch team details:', error);
|
||||
alert({ alertType: 'error', title: t('workspace.teams.loadError', 'Failed to load team details') });
|
||||
@@ -171,6 +175,26 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockUser = async (user: User) => {
|
||||
const confirmMessage = t('workspace.people.confirmUnlock', 'Are you sure you want to unlock this user account?');
|
||||
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userManagementService.unlockUser(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.unlockUserSuccess', 'User account unlocked successfully') });
|
||||
fetchTeamDetails();
|
||||
} catch (error: any) {
|
||||
console.error('[TeamDetailsSection] Failed to unlock user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.unlockUserError', 'Failed to unlock user account');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const openChangeTeamModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedTeamId(user.team?.id?.toString() || '');
|
||||
@@ -335,22 +359,29 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{isLockedUser(user) && (
|
||||
<Badge color="orange" variant="light" size="xs">
|
||||
{t('workspace.people.lockedBadge', 'Locked')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
@@ -420,6 +451,15 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
{isLockedUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock-open" width="1rem" height="1rem" />}
|
||||
onClick={() => handleUnlockUser(user)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t('workspace.people.unlockAccount', 'Unlock Account')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{team.name !== 'Internal' && team.name !== 'Default' && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface AdminSettingsData {
|
||||
premiumEnabled: boolean;
|
||||
mailEnabled: boolean;
|
||||
userSettings?: Record<string, any>;
|
||||
lockedUsers?: string[];
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
@@ -302,6 +303,15 @@ export const userManagementService = {
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unlock a locked user account (admin only)
|
||||
*/
|
||||
async unlockUser(username: string): Promise<void> {
|
||||
await apiClient.post(`/api/v1/user/admin/unlockUser/${username}`, null, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable MFA for a user (admin only)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user