mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-09-14 14:52:26 +03:00
Add Change Password
section to account settings
This commit is contained in:
parent
a41dee4a55
commit
ec40f30a55
3 changed files with 391 additions and 0 deletions
|
@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
|
||||||
import { Profile } from './Profile';
|
import { Profile } from './Profile';
|
||||||
import { ContactInformation } from './ContactInfo';
|
import { ContactInformation } from './ContactInfo';
|
||||||
import { IgnoredUserList } from './IgnoredUserList';
|
import { IgnoredUserList } from './IgnoredUserList';
|
||||||
|
import { ChangePassword } from './ChangePassword';
|
||||||
|
|
||||||
type AccountProps = {
|
type AccountProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
@ -33,6 +34,7 @@ export function Account({ requestClose }: AccountProps) {
|
||||||
<Profile />
|
<Profile />
|
||||||
<MatrixId />
|
<MatrixId />
|
||||||
<ContactInformation />
|
<ContactInformation />
|
||||||
|
<ChangePassword />
|
||||||
<IgnoredUserList />
|
<IgnoredUserList />
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
347
src/app/features/settings/account/ChangePassword.tsx
Normal file
347
src/app/features/settings/account/ChangePassword.tsx
Normal file
|
@ -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 (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap>
|
||||||
|
<Dialog>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Password Changed</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onClose} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box
|
||||||
|
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Text size="T200">
|
||||||
|
Your password has been successfully changed. Your other devices may need to be
|
||||||
|
re-verified.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Primary" onClick={onClose}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Continue
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLFormElement> = (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 (
|
||||||
|
<ActionUIAFlowsLoader
|
||||||
|
authData={ongoingAuthData}
|
||||||
|
unsupported={() => (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap>
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Text>
|
||||||
|
This server requires authentication methods that are not supported by this
|
||||||
|
client.
|
||||||
|
</Text>
|
||||||
|
<Button variant="Primary" onClick={onCancel}>
|
||||||
|
<Text size="B400" as="span">
|
||||||
|
Close
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(ongoingFlow) => (
|
||||||
|
<ActionUIA
|
||||||
|
authData={ongoingAuthData}
|
||||||
|
ongoingFlow={ongoingFlow}
|
||||||
|
action={(authDict) => {
|
||||||
|
if (formData) {
|
||||||
|
handleChangePassword(authDict, formData.newPassword, formData.logoutDevices);
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ActionUIAFlowsLoader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = changePasswordState.status === AsyncStatus.Loading;
|
||||||
|
const error =
|
||||||
|
changePasswordState.status === AsyncStatus.Error ? changePasswordState.error : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap>
|
||||||
|
<Dialog>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Change Password</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Text size="T200">
|
||||||
|
Enter your new password. You may need to re-verify your other devices after
|
||||||
|
changing your password.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ConfirmPasswordMatch initialValue>
|
||||||
|
{(match, doMatch, passRef, confPassRef) => (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">New Password</Text>
|
||||||
|
<PasswordInput
|
||||||
|
ref={passRef}
|
||||||
|
onChange={doMatch}
|
||||||
|
name="newPassword"
|
||||||
|
size="400"
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Confirm New Password</Text>
|
||||||
|
<PasswordInput
|
||||||
|
ref={confPassRef}
|
||||||
|
onChange={doMatch}
|
||||||
|
name="confirmPassword"
|
||||||
|
size="400"
|
||||||
|
style={{ color: match ? undefined : color.Critical.Main }}
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ConfirmPasswordMatch>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<input type="checkbox" id="logoutDevices" name="logoutDevices" defaultChecked />
|
||||||
|
<Text as="label" htmlFor="logoutDevices" size="T300">
|
||||||
|
Sign out all other devices
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Recommended for security. Unchecking this may leave your other devices logged
|
||||||
|
in.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
||||||
|
<Icon size="50" src={Icons.Warning} filled />
|
||||||
|
<Text size="T200">
|
||||||
|
<b>Failed to change password: {error.message}</b>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box gap="200" justifyContent="End">
|
||||||
|
<Button type="button" variant="Secondary" onClick={onCancel} disabled={isLoading}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Primary" type="submit" disabled={isLoading}>
|
||||||
|
{isLoading && <Spinner variant="Primary" size="300" />}
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Change Password
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Password</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Change Password"
|
||||||
|
description={
|
||||||
|
disableChangePassword
|
||||||
|
? 'Password changes are disabled by your server administrator.'
|
||||||
|
: 'Change your account password. This will require authentication with your current password.'
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
size="400"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
onClick={handleOpenDialog}
|
||||||
|
disabled={disableChangePassword}
|
||||||
|
>
|
||||||
|
<Text size="B400">Change</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{showDialog && <ChangePasswordForm onCancel={handleCloseDialog} onSuccess={handleSuccess} />}
|
||||||
|
|
||||||
|
{showSuccess && <ChangePasswordSuccess onClose={handleCloseDialog} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
42
src/app/utils/changePassword.ts
Normal file
42
src/app/utils/changePassword.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import { AuthDict, IAuthData, MatrixClient, MatrixError } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export type ChangePasswordResponse = Record<string, never>;
|
||||||
|
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<ChangePasswordResult> => {
|
||||||
|
|
||||||
|
// 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<ChangePasswordResponse, MatrixError>(
|
||||||
|
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];
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue