diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 2c77ddddd3..3e1fac4077 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -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> userSettings; + private List lockedUsers; } @Data diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index c2d62d5e72..18caceefcf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -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; 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) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java index 4db97a1380..d715771b59 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/LoginAttemptService.java @@ -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 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 diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java index 906802fe22..b8552e274c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -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 diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java index 678bb650a3..f5ea59f105 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java @@ -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"); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java index fd6733d6d2..ead0c6a7d5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java @@ -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(); + 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(); + 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()); + + var method = svc.getClass().getMethod("getAllBlockedUsers"); + @SuppressWarnings("unchecked") + List result = (List) 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(); + 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 result = (List) 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(); + 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 result = (List) method.invoke(svc); + + assertTrue(result.isEmpty(), "Should return empty list when no users exceed MAX_ATTEMPT"); + } } diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 4550ce99f3..6ba6ca42be 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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] diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index be65e40475..2b784511c7 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -51,6 +51,7 @@ export default function PeopleSection() { const [selectedUser, setSelectedUser] = useState(null); const [processing, setProcessing] = useState(false); const [mailEnabled, setMailEnabled] = useState(false); + const [lockedUsers, setLockedUsers] = useState([]); // 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() { )} + {lockedUsers.length > 0 && ( + + {lockedUsers.length} {t('workspace.people.locked', 'locked')} + + )} + {licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && ( +{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')} @@ -497,22 +527,29 @@ export default function PeopleSection() { - - - {user.username} - - + + + + {user.username} + + + {isLockedUser(user) && ( + + {t('workspace.people.lockedBadge', 'Locked')} + + )} + {user.email && ( {user.email} @@ -612,6 +649,15 @@ export default function PeopleSection() { {user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')} )} + {!isCurrentUser(user) && isLockedUser(user) && ( + } + onClick={() => handleUnlockUser(user)} + disabled={!loginEnabled} + > + {t('workspace.people.unlockAccount', 'Unlock Account')} + + )} {!isCurrentUser(user) && user.mfaEnabled && ( <> diff --git a/frontend/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx index dc45856537..dd1f493a43 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx @@ -51,6 +51,9 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio availableSlots: number; } | null>(null); const [mailEnabled, setMailEnabled] = useState(false); + const [lockedUsers, setLockedUsers] = useState([]); + + 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 - - - {user.username} - - + + + + {user.username} + + + {isLockedUser(user) && ( + + {t('workspace.people.lockedBadge', 'Locked')} + + )} + {user.email && ( {user.email} @@ -420,6 +451,15 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio > {t('workspace.people.changePassword.action', 'Change password')} + {isLockedUser(user) && ( + } + onClick={() => handleUnlockUser(user)} + disabled={processing} + > + {t('workspace.people.unlockAccount', 'Unlock Account')} + + )} {team.name !== 'Internal' && team.name !== 'Default' && ( } diff --git a/frontend/src/proprietary/services/userManagementService.ts b/frontend/src/proprietary/services/userManagementService.ts index 11d7d66b9e..0dafb670dd 100644 --- a/frontend/src/proprietary/services/userManagementService.ts +++ b/frontend/src/proprietary/services/userManagementService.ts @@ -40,6 +40,7 @@ export interface AdminSettingsData { premiumEnabled: boolean; mailEnabled: boolean; userSettings?: Record; + 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 { + await apiClient.post(`/api/v1/user/admin/unlockUser/${username}`, null, { + suppressErrorToast: true, + } as any); + }, + /** * Disable MFA for a user (admin only) */