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 d335eb9c2..7e290f0ae 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 @@ -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 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 f2b3fd510..6a18aeff0 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 @@ -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 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( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 8207eae28..c53893a8b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -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(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 8df76fca3..d4ecf8161 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -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 + ? "" + : """ +
+

Temporary Password: %s

+
+ """ + .formatted(newPassword); + + String body = + """ + +
+
+
+ \"Stirling +
+
+

Your password was changed

+

Hello %s,

+

An administrator has updated the password for your Stirling PDF account.

+ %s +

If you did not expect this change, please contact your administrator immediately.

+
+ Go to Stirling PDF +
+

Or copy and paste this link in your browser:

+
+ %s +
+
+
+ © 2025 Stirling PDF. All rights reserved. +
+
+
+ + """ + .formatted(username, passwordSection, loginUrl, loginUrl); + + sendPlainEmail(to, subject, body, true); + } } diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index fcdcf592f..9d9278922 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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." diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 45d1ef454..514b98e1c 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -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) => { persistRuntimeState(updates); diff --git a/frontend/src/proprietary/components/shared/ChangeUserPasswordModal.tsx b/frontend/src/proprietary/components/shared/ChangeUserPasswordModal.tsx new file mode 100644 index 000000000..9e0cd222c --- /dev/null +++ b/frontend/src/proprietary/components/shared/ChangeUserPasswordModal.tsx @@ -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 ( + +
+ + + + + + {t('workspace.people.changePassword.title', 'Change password')} + + + {t('workspace.people.changePassword.subtitle', 'Update the password for')} {user?.username} + + + + + setForm({ ...form, newPassword: event.currentTarget.value, generateRandom: false })} + disabled={processing || disabled || form.generateRandom} + data-autofocus + /> + 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} + /> + + { + const checked = event.currentTarget.checked; + setForm((prev) => ({ ...prev, generateRandom: checked })); + if (event.currentTarget.checked) { + handleGeneratePassword(); + } + }} + /> + {passwordPreview && ( + + + {t('workspace.people.changePassword.generatedPreview', 'Generated password:')} {passwordPreview} + + + + + + + + )} + + + + + setForm({ ...form, sendEmail: event.currentTarget.checked })} + disabled={!canEmail || processing} + /> + setForm({ ...form, includePassword: event.currentTarget.checked })} + disabled={!canEmail || !form.sendEmail || processing} + /> + setForm({ ...form, forcePasswordChange: event.currentTarget.checked })} + disabled={processing || disabled} + /> + {!canEmail && ( + + {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.')} + + )} + {canEmail && !form.includePassword && form.sendEmail && ( + + {t('workspace.people.changePassword.notifyOnly', 'An email will be sent without the password, letting the user know an admin changed it.')} + + )} + + + + +
+
+ ); +} diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 9c4f56ba0..bc2f69c3a 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -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(null); const [selectedUser, setSelectedUser] = useState(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() { - openEditModal(user)} disabled={!loginEnabled}>{t('workspace.people.editRole')} + } + onClick={() => openEditModal(user)} + disabled={!loginEnabled} + > + {t('workspace.people.editRole')} + + } + onClick={() => openChangePasswordModal(user)} + disabled={!loginEnabled} + > + {t('workspace.people.changePassword.action', 'Change password')} + : } onClick={() => handleToggleEnabled(user)} @@ -591,6 +620,14 @@ export default function PeopleSection() { onSuccess={fetchData} /> + + {/* Edit User Modal */} >({}); const [addMemberModalOpened, setAddMemberModalOpened] = useState(false); const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false); + const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false); + const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); const [selectedUserId, setSelectedUserId] = useState(''); const [selectedTeamId, setSelectedTeamId] = useState(''); @@ -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')} + } + onClick={() => openChangePasswordModal(user)} + disabled={processing} + > + {t('workspace.people.changePassword.action', 'Change password')} + {team.name !== 'Internal' && team.name !== 'Default' && ( } @@ -427,6 +449,14 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio + + {/* Add Member Modal */} ('/api/v1/invite/cleanup'); return response.data; }, + + /** + * Change another user's password (admin only) + */ + async changeUserPassword(data: ChangeUserPasswordRequest): Promise { + 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); + }, };