From ec40f30a5575239a139dbf72f92c09ec3394d99c Mon Sep 17 00:00:00 2001 From: Thomas Q Date: Thu, 7 Aug 2025 10:29:26 -0300 Subject: [PATCH] Add `Change Password` section to account settings --- src/app/features/settings/account/Account.tsx | 2 + .../settings/account/ChangePassword.tsx | 347 ++++++++++++++++++ src/app/utils/changePassword.ts | 42 +++ 3 files changed, 391 insertions(+) create mode 100644 src/app/features/settings/account/ChangePassword.tsx create mode 100644 src/app/utils/changePassword.ts diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index c4b56e47..d5276fc4 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId'; import { Profile } from './Profile'; import { ContactInformation } from './ContactInfo'; import { IgnoredUserList } from './IgnoredUserList'; +import { ChangePassword } from './ChangePassword'; type AccountProps = { requestClose: () => void; @@ -33,6 +34,7 @@ export function Account({ requestClose }: AccountProps) { + diff --git a/src/app/features/settings/account/ChangePassword.tsx b/src/app/features/settings/account/ChangePassword.tsx new file mode 100644 index 00000000..2e394918 --- /dev/null +++ b/src/app/features/settings/account/ChangePassword.tsx @@ -0,0 +1,347 @@ +import React, { FormEventHandler, useCallback, useState, useEffect } from 'react'; +import { + Box, + Text, + Button, + Overlay, + OverlayBackdrop, + OverlayCenter, + Dialog, + Header, + Icon, + IconButton, + Icons, + config, + Spinner, + color, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import AuthDict from 'matrix-js-sdk'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { PasswordInput } from '../../../components/password-input'; +import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA'; +import { changePassword, ChangePasswordResult } from '../../../utils/changePassword'; +import { useCapabilities } from '../../../hooks/useCapabilities'; + +function ChangePasswordSuccess({ onClose }: { onClose: () => void }) { + return ( + }> + + + +
+ + Password Changed + + + + +
+ + + + Your password has been successfully changed. Your other devices may need to be + re-verified. + + + + +
+
+
+
+ ); +} + +type ChangePasswordFormProps = { + onCancel: () => void; + onSuccess: () => void; +}; + +function ChangePasswordForm({ onCancel, onSuccess }: ChangePasswordFormProps) { + const mx = useMatrixClient(); + const [formData, setFormData] = useState<{ newPassword: string; logoutDevices: boolean } | null>( + null + ); + + const [changePasswordState, handleChangePassword] = useAsyncCallback< + ChangePasswordResult, + Error, + [AuthDict | undefined, string, boolean] + >( + useCallback( + async (authDict, newPassword, logoutDevices) => + changePassword(mx, authDict, newPassword, logoutDevices), + [mx] + ) + ); + + const [ongoingAuthData, changePasswordResult] = + changePasswordState.status === AsyncStatus.Success + ? changePasswordState.data + : [undefined, undefined]; + + const handleFormSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + + const formDataObj = new FormData(evt.currentTarget); + const newPassword = formDataObj.get('newPassword') as string; + const confirmPassword = formDataObj.get('confirmPassword') as string; + const logoutDevices = formDataObj.get('logoutDevices') === 'on'; + + if (!newPassword || !confirmPassword) return; + if (newPassword !== confirmPassword) return; + + // Store form data for UIA completion + const formState = { newPassword, logoutDevices }; + setFormData(formState); + + // Just call the async callback - don't handle the result here + // The component state will automatically update and handle UIA vs success + handleChangePassword(undefined, newPassword, logoutDevices); + }; + + // Handle successful completion + useEffect(() => { + if (changePasswordResult && !ongoingAuthData) { + onSuccess(); + } + }, [changePasswordResult, ongoingAuthData, onSuccess]); + + // Don't show success dialog in this component - let parent handle it + if (changePasswordResult && !ongoingAuthData) { + return null; // Success state handled by parent component + } + + // Show UIA flow if we have auth data + if (ongoingAuthData) { + return ( + ( + }> + + + + + + This server requires authentication methods that are not supported by this + client. + + + + + + + + )} + > + {(ongoingFlow) => ( + { + if (formData) { + handleChangePassword(authDict, formData.newPassword, formData.logoutDevices); + } else { + onCancel(); + } + }} + onCancel={onCancel} + /> + )} + + ); + } + + const isLoading = changePasswordState.status === AsyncStatus.Loading; + const error = + changePasswordState.status === AsyncStatus.Error ? changePasswordState.error : undefined; + + return ( + }> + + + +
+ + Change Password + + + + +
+ + + + Enter your new password. You may need to re-verify your other devices after + changing your password. + + + + {(match, doMatch, passRef, confPassRef) => ( + <> + + New Password + + + + Confirm New Password + + + + )} + + + + + + + Sign out all other devices + + + + Recommended for security. Unchecking this may leave your other devices logged + in. + + + + {error && ( + + + + Failed to change password: {error.message} + + + )} + + + + + + + +
+
+
+
+ ); +} + +export function ChangePassword() { + const [showDialog, setShowDialog] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const capabilities = useCapabilities(); + + // Check if password change is disabled by server capabilities + const disableChangePassword = capabilities['m.change_password']?.enabled === false; + + const handleOpenDialog = () => setShowDialog(true); + const handleCloseDialog = () => { + setShowDialog(false); + setShowSuccess(false); + }; + const handleSuccess = () => { + setShowDialog(false); + setShowSuccess(true); + }; + + return ( + <> + + Password + + + Change + + } + /> + + + + {showDialog && } + + {showSuccess && } + + ); +} diff --git a/src/app/utils/changePassword.ts b/src/app/utils/changePassword.ts new file mode 100644 index 00000000..5e91f51f --- /dev/null +++ b/src/app/utils/changePassword.ts @@ -0,0 +1,42 @@ +import to from 'await-to-js'; +import { AuthDict, IAuthData, MatrixClient, MatrixError } from 'matrix-js-sdk'; + +export type ChangePasswordResponse = Record; +export type ChangePasswordResult = [IAuthData, undefined] | [undefined, ChangePasswordResponse]; + +/** + * Change the user's password using the Matrix password change API + * @param mx Matrix client instance + * @param authDict Authentication dictionary for UIA (undefined for initial request) + * @param newPassword The new password to set + * @param logoutDevices Whether to logout other devices (defaults to true for security) + * @returns Tuple with either auth data (for UIA continuation) or success response + */ +export const changePassword = async ( + mx: MatrixClient, + authDict: AuthDict | undefined, + newPassword: string, + logoutDevices = true +): Promise => { + + // For the initial request, pass undefined instead of null + // This ensures the auth field is omitted from the request body + const [err, res] = await to( + mx.setPassword(authDict, newPassword, logoutDevices) + ); + + if (err) { + console.log('Password change error:', err.httpStatus, err.data); + // If we get a 401, it means we need to perform UIA + if (err.httpStatus === 401) { + const authData = err.data as IAuthData; + return [authData, undefined]; + } + // Any other error should be thrown + throw err; + } + + console.log('Password change successful:', res); + // Success case - return empty response + return [undefined, res]; +};