Add admin password reset option for users (#5180)

## Summary
- add backend support for admins to reset user passwords and optionally
email notifications when SMTP is enabled
- surface mail capability in admin settings data for the UI
- add a shared change-password modal hooked into People and Team user
actions with random password generation and email options

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934b978fe3c83289b5b95dec79b3d38)

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2025-12-10 10:10:40 +00:00
committed by GitHub
parent c980ee10c0
commit 9c03914edd
10 changed files with 577 additions and 11 deletions

View File

@@ -328,6 +328,7 @@ public class ProprietaryUIDataController {
data.setGrandfatheredUserCount(grandfatheredCount);
data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
return ResponseEntity.ok(data);
}
@@ -376,7 +377,7 @@ public class ProprietaryUIDataController {
data.setUsername(username);
data.setRole(user.get().getRolesAsString());
data.setSettings(settingsJson);
data.setChangeCredsFlag(user.get().isFirstLogin());
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login);
@@ -510,6 +511,7 @@ public class ProprietaryUIDataController {
private int grandfatheredUserCount;
private int licenseMaxUsers;
private boolean premiumEnabled;
private boolean mailEnabled;
}
@Data

View File

@@ -7,6 +7,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.*;
import jakarta.mail.MessagingException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
@@ -236,6 +238,8 @@ public class UserController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
}
// Set flags before changing password so they're saved together
user.setForcePasswordChange(false);
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
@@ -584,6 +588,79 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changePasswordForUser")
public ResponseEntity<?> changePasswordForUser(
@RequestParam(name = "username") String username,
@RequestParam(name = "newPassword", required = false) String newPassword,
@RequestParam(name = "generateRandom", defaultValue = "false") boolean generateRandom,
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
@RequestParam(name = "includePassword", defaultValue = "false") boolean includePassword,
@RequestParam(name = "forcePasswordChange", defaultValue = "false")
boolean forcePasswordChange,
HttpServletRequest request,
Authentication authentication)
throws SQLException, UnsupportedProviderException, MessagingException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
String currentUsername = authentication.getName();
if (currentUsername.equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot change your own password."));
}
User user = userOpt.get();
String finalPassword = newPassword;
if (generateRandom) {
finalPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
if (finalPassword == null || finalPassword.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "New password is required."));
}
// Set force password change flag before changing password so both are saved together
user.setForcePasswordChange(forcePasswordChange);
userService.changePassword(user, finalPassword);
// Invalidate all active sessions to force reauthentication
userService.invalidateUserSessions(username);
if (sendEmail) {
if (emailService.isEmpty() || !applicationProperties.getMail().isEnabled()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email is not configured."));
}
String userEmail = user.getUsername();
// Check if username is a valid email format
if (userEmail == null || userEmail.isBlank() || !userEmail.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"User's email is not a valid email address. Notifications are disabled."));
}
String loginUrl = buildLoginUrl(request);
emailService
.get()
.sendPasswordChangedNotification(
userEmail,
user.getUsername(),
includePassword ? finalPassword : null,
loginUrl);
}
return ResponseEntity.ok(Map.of("message", "User password updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeUserEnabled/{username}")
public ResponseEntity<?> changeUserEnabled(

View File

@@ -59,6 +59,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "forcePasswordChange")
private Boolean forcePasswordChange = false;
@Column(name = "roleName")
private String roleName;
@@ -117,6 +120,14 @@ public class User implements UserDetails, Serializable {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public boolean isForcePasswordChange() {
return forcePasswordChange != null && forcePasswordChange;
}
public void setForcePasswordChange(boolean forcePasswordChange) {
this.forcePasswordChange = forcePasswordChange;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}

View File

@@ -223,4 +223,53 @@ public class EmailService {
sendPlainEmail(to, subject, body, true);
}
@Async
public void sendPasswordChangedNotification(
String to, String username, String newPassword, String loginUrl) throws MessagingException {
String subject = "Your Stirling PDF password has been updated";
String passwordSection =
newPassword == null
? ""
: """
<div style=\"background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;\">
<p style=\"margin: 0;\"><strong>Temporary Password:</strong> %s</p>
</div>
"""
.formatted(newPassword);
String body =
"""
<html><body style=\"margin: 0; padding: 0;\">
<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">
<div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">
<div style=\"text-align: center; padding: 20px; background-color: #222;\">
<img src=\"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg\" alt=\"Stirling PDF\" style=\"max-height: 60px;\">
</div>
<div style=\"padding: 30px; color: #333;\">
<h2 style=\"color: #222; margin-top: 0;\">Your password was changed</h2>
<p>Hello %s,</p>
<p>An administrator has updated the password for your Stirling PDF account.</p>
%s
<p>If you did not expect this change, please contact your administrator immediately.</p>
<div style=\"text-align: center; margin: 30px 0;\">
<a href=\"%s\" style=\"display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;\">Go to Stirling PDF</a>
</div>
<p style=\"font-size: 14px; color: #666;\">Or copy and paste this link in your browser:</p>
<div style=\"background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;\">
%s
</div>
</div>
<div style=\"text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;\">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, passwordSection, loginUrl, loginUrl);
sendPlainEmail(to, subject, body, true);
}
}

View File

@@ -5368,6 +5368,31 @@ error = "Failed to update user status"
success = "User deleted successfully"
error = "Failed to delete user"
[workspace.people.changePassword]
action = "Change password"
title = "Change password"
subtitle = "Update the password for"
newPassword = "New password"
confirmPassword = "Confirm password"
placeholder = "Enter a new password"
confirmPlaceholder = "Re-enter the new password"
passwordRequired = "Please enter a new password"
passwordMismatch = "Passwords do not match"
generateRandom = "Generate secure password"
generatedPreview = "Generated password:"
copyTooltip = "Copy to clipboard"
copiedToClipboard = "Password copied to clipboard"
copyFailed = "Failed to copy password"
sendEmail = "Email the user about this change"
includePassword = "Include the new password in the email"
forcePasswordChange = "Force user to change password on next login"
emailUnavailable = "This user's email is not a valid email address. Notifications are disabled."
smtpDisabled = "Email notifications require SMTP to be enabled in settings."
notifyOnly = "An email will be sent without the password, letting the user know an admin changed it."
submit = "Update password"
success = "Password updated successfully"
error = "Failed to update password"
[workspace.people.emailInvite]
tab = "Email Invite"
description = "Type or paste in emails below, separated by commas. Users will receive login credentials via email."

View File

@@ -195,7 +195,7 @@ export function useOnboardingOrchestrator(
accountService.getAccountData(),
accountService.getLoginPageData(),
]);
setRuntimeState((prev) => ({
...prev,
requiresPasswordChange: accountData.changeCredsFlag,
@@ -226,8 +226,12 @@ export function useOnboardingOrchestrator(
}), [serverExperience, runtimeState]);
const activeFlow = useMemo(() => {
// If password change is required, ONLY show the first-login step
if (runtimeState.requiresPasswordChange) {
return ONBOARDING_STEPS.filter((step) => step.id === 'first-login');
}
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext]);
}, [conditionContext, runtimeState.requiresPasswordChange]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
@@ -238,23 +242,31 @@ export function useOnboardingOrchestrator(
useEffect(() => {
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
let firstUnseenIndex = -1;
for (let i = 0; i < activeFlow.length; i++) {
if (!hasSeenStep(activeFlow[i].id)) {
// Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
const isFirstLoginStep = activeFlow[i].id === 'first-login';
const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
if (shouldTreatAsUnseen) {
firstUnseenIndex = i;
break;
}
}
if (firstUnseenIndex === -1) {
// Force reset index when password change is required (overrides initialIndexSet)
if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
} else if (firstUnseenIndex === -1) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
} else if (!initialIndexSet.current) {
setCurrentStepIndex(firstUnseenIndex);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved]);
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
const totalSteps = activeFlow.length;
@@ -303,10 +315,13 @@ export function useOnboardingOrchestrator(
if (!currentStep || isLoading) {
return;
}
if (hasSeenStep(currentStep.id)) {
// Special case: never auto-complete first-login step if requiresPasswordChange is true
const isFirstLoginStep = currentStep.id === 'first-login';
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
complete();
}
}, [currentStep, isLoading, complete]);
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);

View File

@@ -0,0 +1,283 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActionIcon,
Button,
Checkbox,
CloseButton,
Group,
Modal,
PasswordInput,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { ChangeUserPasswordRequest, User, userManagementService } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface ChangeUserPasswordModalProps {
opened: boolean;
onClose: () => void;
user: User | null;
onSuccess: () => void;
mailEnabled: boolean;
}
function generateSecurePassword() {
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&';
const length = 14;
let password = '';
const charsetLength = charset.length;
const uint8Array = new Uint8Array(length);
window.crypto.getRandomValues(uint8Array);
// To avoid modulo bias, discard values >= 256 - (256 % charsetLength)
for (let i = 0; password.length < length; ) {
const randomByte = uint8Array[i];
i++;
if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) {
// Discard and generate a new random value
if (i >= uint8Array.length) {
// Exhausted the array, fill a new one
window.crypto.getRandomValues(uint8Array);
i = 0;
}
continue;
}
const randomIndex = randomByte % charsetLength;
password += charset[randomIndex];
}
return password;
}
export default function ChangeUserPasswordModal({ opened, onClose, user, onSuccess, mailEnabled }: ChangeUserPasswordModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState({
newPassword: '',
confirmPassword: '',
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
const [processing, setProcessing] = useState(false);
const disabled = !user;
const handleGeneratePassword = () => {
const generated = generateSecurePassword();
setForm((prev) => ({ ...prev, newPassword: generated, confirmPassword: generated, generateRandom: true }));
};
const handleCopyPassword = async () => {
if (!form.newPassword) return;
try {
await navigator.clipboard.writeText(form.newPassword);
alert({ alertType: 'success', title: t('workspace.people.changePassword.copiedToClipboard', 'Password copied to clipboard') });
} catch (_error) {
alert({ alertType: 'error', title: t('workspace.people.changePassword.copyFailed', 'Failed to copy password') });
}
};
const resetState = () => {
setForm({
newPassword: '',
confirmPassword: '',
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
};
const handleClose = () => {
if (processing) return;
resetState();
onClose();
};
const handleSubmit = async () => {
if (!user) return;
if (!form.generateRandom && !form.newPassword.trim()) {
alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordRequired', 'Please enter a new password') });
return;
}
if (!form.generateRandom && form.newPassword !== form.confirmPassword) {
alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') });
return;
}
const payload: ChangeUserPasswordRequest = {
username: user.username,
newPassword: form.newPassword, // Always send the password (frontend generates it when generateRandom is true)
generateRandom: false, // Not needed since we're generating on frontend
sendEmail: form.sendEmail,
includePassword: form.includePassword,
forcePasswordChange: form.forcePasswordChange,
};
try {
setProcessing(true);
await userManagementService.changeUserPassword(payload);
alert({ alertType: 'success', title: t('workspace.people.changePassword.success', 'Password updated successfully') });
onSuccess();
handleClose();
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.changePassword.error', 'Failed to update password');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
useEffect(() => {
if (opened) {
setForm({
newPassword: '',
confirmPassword: '',
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
}
}, [opened, user?.username]);
// Check if username is a valid email format
const isValidEmail = (email: string | undefined) => {
if (!email) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const canEmail = mailEnabled && isValidEmail(user?.username);
const passwordPreview = useMemo(() => form.newPassword && form.generateRandom ? form.newPassword : '', [form.generateRandom, form.newPassword]);
return (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={handleClose}
size="lg"
disabled={processing}
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
<Stack gap="md" align="center">
<LocalIcon icon="lock" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.changePassword.title', 'Change password')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.people.changePassword.subtitle', 'Update the password for')} <strong>{user?.username}</strong>
</Text>
</Stack>
<Stack gap="sm">
<PasswordInput
label={t('workspace.people.changePassword.newPassword', 'New password')}
placeholder={t('workspace.people.changePassword.placeholder', 'Enter a new password')}
value={form.newPassword}
onChange={(event) => setForm({ ...form, newPassword: event.currentTarget.value, generateRandom: false })}
disabled={processing || disabled || form.generateRandom}
data-autofocus
/>
<PasswordInput
label={t('workspace.people.changePassword.confirmPassword', 'Confirm password')}
placeholder={t('workspace.people.changePassword.confirmPlaceholder', 'Re-enter the new password')}
value={form.confirmPassword}
onChange={(event) => setForm({ ...form, confirmPassword: event.currentTarget.value, generateRandom: false })}
disabled={processing || disabled || form.generateRandom}
error={!form.generateRandom && form.confirmPassword && form.newPassword !== form.confirmPassword ? t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') : undefined}
/>
<Group justify="space-between">
<Checkbox
label={t('workspace.people.changePassword.generateRandom', 'Generate secure password')}
checked={form.generateRandom}
disabled={processing || disabled}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({ ...prev, generateRandom: checked }));
if (event.currentTarget.checked) {
handleGeneratePassword();
}
}}
/>
{passwordPreview && (
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{t('workspace.people.changePassword.generatedPreview', 'Generated password:')} <strong>{passwordPreview}</strong>
</Text>
<Tooltip label={t('workspace.people.changePassword.copyTooltip', 'Copy to clipboard')}>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={handleCopyPassword}
disabled={processing}
>
<LocalIcon icon="content-copy" width="0.9rem" height="0.9rem" />
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Stack>
<Stack gap="xs">
<Checkbox
label={t('workspace.people.changePassword.sendEmail', 'Email the user about this change')}
checked={canEmail && form.sendEmail}
onChange={(event) => setForm({ ...form, sendEmail: event.currentTarget.checked })}
disabled={!canEmail || processing}
/>
<Checkbox
label={t('workspace.people.changePassword.includePassword', 'Include the new password in the email')}
checked={canEmail && form.sendEmail && form.includePassword}
onChange={(event) => setForm({ ...form, includePassword: event.currentTarget.checked })}
disabled={!canEmail || !form.sendEmail || processing}
/>
<Checkbox
label={t('workspace.people.changePassword.forcePasswordChange', 'Force user to change password on next login')}
checked={form.forcePasswordChange}
onChange={(event) => setForm({ ...form, forcePasswordChange: event.currentTarget.checked })}
disabled={processing || disabled}
/>
{!canEmail && (
<Text size="xs" c="dimmed">
{mailEnabled
? t('workspace.people.changePassword.emailUnavailable', "This user's email is not a valid email address. Notifications are disabled.")
: t('workspace.people.changePassword.smtpDisabled', 'Email notifications require SMTP to be enabled in settings.')}
</Text>
)}
{canEmail && !form.includePassword && form.sendEmail && (
<Text size="xs" c="dimmed">
{t('workspace.people.changePassword.notifyOnly', 'An email will be sent without the password, letting the user know an admin changed it.')}
</Text>
)}
</Stack>
<Button onClick={handleSubmit} loading={processing} fullWidth size="md" disabled={disabled} mt="md">
{t('workspace.people.changePassword.submit', 'Update password')}
</Button>
</Stack>
</div>
</Modal>
);
}

View File

@@ -30,6 +30,7 @@ import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBann
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';
export default function PeopleSection() {
const { t } = useTranslation();
@@ -43,8 +44,11 @@ export default function PeopleSection() {
const [searchQuery, setSearchQuery] = useState('');
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
const [passwordUser, setPasswordUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [mailEnabled, setMailEnabled] = useState(false);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
@@ -119,6 +123,7 @@ export default function PeopleSection() {
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
setMailEnabled(adminData.mailEnabled);
} else {
// Provide example data when login is disabled
const exampleUsers: User[] = [
@@ -179,6 +184,7 @@ export default function PeopleSection() {
setUsers(exampleUsers);
setTeams(exampleTeams);
setMailEnabled(false);
// Example license information
setLicenseInfo({
@@ -267,6 +273,16 @@ export default function PeopleSection() {
setEditUserModalOpened(true);
};
const openChangePasswordModal = (user: User) => {
setPasswordUser(user);
setChangePasswordModalOpened(true);
};
const closeChangePasswordModal = () => {
setChangePasswordModalOpened(false);
setPasswordUser(null);
};
const closeEditModal = () => {
setEditUserModalOpened(false);
setSelectedUser(null);
@@ -562,7 +578,20 @@ export default function PeopleSection() {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item onClick={() => openEditModal(user)} disabled={!loginEnabled}>{t('workspace.people.editRole')}</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />}
onClick={() => openEditModal(user)}
disabled={!loginEnabled}
>
{t('workspace.people.editRole')}
</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>
<Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
onClick={() => handleToggleEnabled(user)}
@@ -591,6 +620,14 @@ export default function PeopleSection() {
onSuccess={fetchData}
/>
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
onClose={closeChangePasswordModal}
user={passwordUser}
onSuccess={fetchData}
mailEnabled={mailEnabled}
/>
{/* Edit User Modal */}
<Modal
opened={editUserModalOpened}

View File

@@ -22,6 +22,7 @@ import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { User, userManagementService } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
interface TeamDetailsSectionProps {
teamId: number;
@@ -38,6 +39,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const [userLastRequest, setUserLastRequest] = useState<Record<string, number>>({});
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
const [passwordUser, setPasswordUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
@@ -47,6 +50,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const [licenseInfo, setLicenseInfo] = useState<{
availableSlots: number;
} | null>(null);
const [mailEnabled, setMailEnabled] = useState(false);
useEffect(() => {
fetchTeamDetails();
@@ -70,6 +74,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
setLicenseInfo({
availableSlots: adminData.availableSlots,
});
setMailEnabled(adminData.mailEnabled);
} catch (error) {
console.error('Failed to fetch team details:', error);
alert({ alertType: 'error', title: t('workspace.teams.loadError', 'Failed to load team details') });
@@ -172,6 +177,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
setChangeTeamModalOpened(true);
};
const openChangePasswordModal = (user: User) => {
setPasswordUser(user);
setChangePasswordModalOpened(true);
};
const closeChangePasswordModal = () => {
setChangePasswordModalOpened(false);
setPasswordUser(null);
};
const handleChangeTeam = async () => {
if (!selectedUser || !selectedTeamId) {
alert({ alertType: 'error', title: t('workspace.teams.changeTeam.selectTeamRequired', 'Please select a team') });
@@ -398,6 +413,13 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
>
{t('workspace.teams.changeTeam.label', 'Change Team')}
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)}
disabled={processing}
>
{t('workspace.people.changePassword.action', 'Change password')}
</Menu.Item>
{team.name !== 'Internal' && team.name !== 'Default' && (
<Menu.Item
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
@@ -427,6 +449,14 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
</Table.Tbody>
</Table>
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
onClose={closeChangePasswordModal}
user={passwordUser}
onSuccess={fetchTeamDetails}
mailEnabled={mailEnabled}
/>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}

View File

@@ -37,6 +37,7 @@ export interface AdminSettingsData {
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
mailEnabled: boolean;
}
export interface CreateUserRequest {
@@ -97,6 +98,15 @@ export interface InviteToken {
expiresAt: string;
}
export interface ChangeUserPasswordRequest {
username: string;
newPassword?: string;
generateRandom?: boolean;
sendEmail?: boolean;
includePassword?: boolean;
forcePasswordChange?: boolean;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
@@ -254,4 +264,31 @@ export const userManagementService = {
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
return response.data;
},
/**
* Change another user's password (admin only)
*/
async changeUserPassword(data: ChangeUserPasswordRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
if (data.newPassword) {
formData.append('newPassword', data.newPassword);
}
if (data.generateRandom !== undefined) {
formData.append('generateRandom', data.generateRandom.toString());
}
if (data.sendEmail !== undefined) {
formData.append('sendEmail', data.sendEmail.toString());
}
if (data.includePassword !== undefined) {
formData.append('includePassword', data.includePassword.toString());
}
if (data.forcePasswordChange !== undefined) {
formData.append('forcePasswordChange', data.forcePasswordChange.toString());
}
await apiClient.post('/api/v1/user/admin/changePasswordForUser', formData, {
suppressErrorToast: true, // Component will handle error display
} as any);
},
};