Skip to content

Commit

Permalink
Change password with OTP
Browse files Browse the repository at this point in the history
  • Loading branch information
qrtp committed Jan 22, 2025
1 parent dac1a0d commit 1082d95
Show file tree
Hide file tree
Showing 6 changed files with 343 additions and 20 deletions.
55 changes: 52 additions & 3 deletions packages/ui-components/src/actions/fireBlocksActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {sleep} from '../lib/sleep';
import {
EIP_712_KEY,
FB_MAX_RETRY,
FB_WAIT_TIME_MS
FB_WAIT_TIME_MS,
} from '../lib/types/fireBlocks';
import type {
AccountAsset,
Expand All @@ -27,8 +27,8 @@ import type {
GetOperationStatusResponse,
GetTokenResponse,
TokenRefreshResponse,

VerifyTokenResponse} from '../lib/types/fireBlocks';
VerifyTokenResponse,
} from '../lib/types/fireBlocks';
import {getAsset} from '../lib/wallet/asset';
import {
getBootstrapState,
Expand Down Expand Up @@ -513,6 +513,55 @@ export const recoverTokenOtp = async (
return undefined;
};

export const changePassword = async (
accessToken: string,
currentPassword: string,
newPassword: string,
otp?: string,
): Promise<
| 'OK'
| 'OTP_TOKEN_REQUIRED'
| 'VALIDATION'
| 'INVALID_OTP_TOKEN'
| 'INVALID_PASSWORD'
> => {
try {
// build required headers
const headers: Record<string, string> = {
'Access-Control-Allow-Credentials': 'true',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
};
if (otp) {
headers['X-Otp-Token'] = otp;
}

// make request to change password
const changePwResult = await fetchApi('/v1/settings/security/login', {
method: 'PATCH',
mode: 'cors',
headers,
host: config.WALLETS.HOST_URL,
body: JSON.stringify({
currentPassword,
newPassword,
}),
acceptStatusCodes: [400, 401, 403],
});
if (changePwResult === 'OK') {
return 'OK';
} else if (!changePwResult?.code) {
throw new Error('error changing password');
}
return changePwResult.code;
} catch (e) {
notifyEvent(e, 'error', 'Wallet', 'Fetch', {
msg: 'error changing password',
});
throw e;
}
};

export const sendRecoveryEmail = async (
accessToken: string,
recoveryPassphrase: string,
Expand Down
244 changes: 244 additions & 0 deletions packages/ui-components/src/components/Wallet/ChangePasswordModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import Alert from '@mui/lab/Alert';
import LoadingButton from '@mui/lab/LoadingButton';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import type {Theme} from '@mui/material/styles';
import Markdown from 'markdown-to-jsx';
import React, {useState} from 'react';

import {makeStyles} from '@unstoppabledomains/ui-kit/styles';

import {changePassword} from '../../actions/fireBlocksActions';
import {useTranslationContext} from '../../lib';
import {isValidWalletPasswordFormat} from '../../lib/wallet/password';
import ManageInput from '../Manage/common/ManageInput';
import {OperationStatus} from './OperationStatus';

const useStyles = makeStyles()((theme: Theme) => ({
container: {
display: 'flex',
flexDirection: 'column',
height: '100%',
justifyContent: 'space-between',
},
content: {
display: 'flex',
flexDirection: 'column',
width: '450px',
[theme.breakpoints.down('sm')]: {
width: '100%',
},
marginBottom: theme.spacing(2),
},
button: {
marginTop: theme.spacing(1),
},
passwordIcon: {
margin: theme.spacing(0.5),
},
}));

type Props = {
accessToken?: string;
};

const ChangePasswordModal: React.FC<Props> = ({accessToken}) => {
const {classes} = useStyles();
const [t] = useTranslationContext();
const [currentPassword, setCurrentPassword] = useState<string>();
const [newPassword, setNewPassword] = useState<string>();
const [oneTimeCode, setOneTimeCode] = useState<string>();
const [isSuccess, setIsSuccess] = useState<boolean>();
const [isSaving, setIsSaving] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [isOtpRequired, setIsOtpRequired] = useState<'TOTP' | 'EMAIL'>();
const [errorMessage, setErrorMessage] = useState<string>();
const [showContinueButton, setShowContinueButton] = useState(true);
const [showBackButton, setShowBackButton] = useState(false);

const handleValueChanged = (id: string, v: string) => {
if (id === 'current-password') {
setCurrentPassword(v);
} else if (id === 'new-password') {
setNewPassword(v);
} else if (id === 'oneTimeCode') {
setOneTimeCode(v);
}
setIsDirty(true);
setIsSuccess(undefined);
};

const handleKeyDown: React.KeyboardEventHandler = event => {
if (event.key === 'Enter') {
void handleChangePassword();
}
};

const handleBack = () => {
setOneTimeCode(undefined);
setIsOtpRequired(undefined);
setErrorMessage(undefined);
setShowContinueButton(true);
setShowBackButton(false);
};

const handleChangePassword = async () => {
// validate password and token
if (!accessToken || !currentPassword || !newPassword) {
return;
}

// validate password strength
if (!isValidWalletPasswordFormat(newPassword)) {
setErrorMessage(t('wallet.resetPasswordStrength'));
setNewPassword(undefined);
setIsDirty(false);
return;
}

// clear state
setIsSaving(true);
setErrorMessage(undefined);
setIsDirty(false);

// request a new recovery kit
const result = await changePassword(
accessToken,
currentPassword,
newPassword,
oneTimeCode,
);

// clear state
setIsSaving(false);

// determine success
if (result === 'OK') {
setShowBackButton(false);
setShowContinueButton(false);
setIsSuccess(true);
} else if (result === 'OTP_TOKEN_REQUIRED') {
setIsOtpRequired('TOTP');
} else if (result === 'VALIDATION') {
setErrorMessage(t('wallet.resetPasswordStrength'));
} else if (result === 'INVALID_OTP_TOKEN') {
// invalid OTP
setShowContinueButton(true);
setShowBackButton(false);
setOneTimeCode(undefined);
setErrorMessage(t('wallet.signInOtpError'));
} else if (result === 'INVALID_PASSWORD') {
// invalid username or password
setOneTimeCode(undefined);
setShowContinueButton(false);
setShowBackButton(true);
setErrorMessage(t('wallet.signInError'));
} else {
// unknown error
setOneTimeCode(undefined);
setShowContinueButton(false);
setShowBackButton(true);
setErrorMessage(t('common.unknownError'));
}
};

// show loading spinner until access token available
if (!accessToken) {
return (
<Box display="flex" justifyContent="center">
<CircularProgress />
</Box>
);
}

return (
<Box className={classes.container}>
<Box className={classes.content}>
<Typography variant="body2" mb={1} mt={-2} component="div">
<Markdown>{t('wallet.changeRecoveryPhraseDescription')}</Markdown>
</Typography>
{isSuccess ? (
<Box mt={3}>
<OperationStatus
success={true}
label={t('wallet.changeRecoveryPhraseSuccess')}
/>
</Box>
) : isOtpRequired ? (
<Box>
<ManageInput
id="oneTimeCode"
value={oneTimeCode}
autoComplete="one-time-code"
label={t('wallet.oneTimeCode')}
placeholder={t('wallet.enterOneTimeCode')}
onChange={handleValueChanged}
onKeyDown={handleKeyDown}
stacked={true}
disabled={isSaving}
/>
</Box>
) : (
<Box>
<ManageInput
id="current-password"
type={'password'}
autoComplete="current-password"
label={`${t('common.current')} ${t('wallet.recoveryPhrase')}`}
placeholder={t('wallet.enterRecoveryPhrase')}
value={currentPassword}
onChange={handleValueChanged}
stacked={true}
disabled={isSaving}
onKeyDown={handleKeyDown}
/>
<ManageInput
id="new-password"
type={'password'}
autoComplete="new-password"
label={`${t('common.new')} ${t('wallet.recoveryPhrase')}`}
placeholder={t('wallet.enterRecoveryPhrase')}
value={newPassword}
onChange={handleValueChanged}
mt={1}
stacked={true}
disabled={isSaving}
onKeyDown={handleKeyDown}
/>
</Box>
)}
{errorMessage && (
<Box mt={3}>
<Alert severity="error">{errorMessage}</Alert>
</Box>
)}
</Box>
{showContinueButton && (
<LoadingButton
variant="contained"
fullWidth
loading={isSaving}
onClick={handleChangePassword}
className={classes.button}
disabled={isSaving || !isDirty}
>
{t('common.continue')}
</LoadingButton>
)}
{showBackButton && (
<Button
className={classes.button}
onClick={handleBack}
variant="outlined"
fullWidth
>
{t('common.back')}
</Button>
)}
</Box>
);
};

export default ChangePasswordModal;
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import {makeStyles} from '@unstoppabledomains/ui-kit/styles';

import {getTwoFactorStatus} from '../../actions/walletMfaActions';
import {useTranslationContext} from '../../lib';
import {useCustomTheme} from '../../styles/theme';
import Modal from '../Modal';
import ChangePasswordModal from './ChangePasswordModal';
import RecoverySetupModal from './RecoverySetupModal';
import {TwoFactorModal} from './TwoFactorModal';
import {WalletPreference} from './WalletPreference';
Expand Down Expand Up @@ -55,9 +55,9 @@ type Props = {
const SecurityCenterModal: React.FC<Props> = ({accessToken}) => {
const {classes} = useStyles();
const [t] = useTranslationContext();
const theme = useCustomTheme();
const [isLoaded, setIsLoaded] = useState(false);
const [isRecoveryModalOpen, setIsRecoveryModalOpen] = useState(false);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [isMfaModalOpen, setIsMfaModalOpen] = useState(false);
const [isMfaEnabled, setIsMfaEnabled] = useState(false);

Expand All @@ -81,6 +81,10 @@ const SecurityCenterModal: React.FC<Props> = ({accessToken}) => {
setIsRecoveryModalOpen(true);
};

const handleChangePasswordClicked = () => {
setIsPasswordModalOpen(true);
};

const handleMfaClicked = () => {
setIsMfaModalOpen(true);
};
Expand All @@ -101,7 +105,15 @@ const SecurityCenterModal: React.FC<Props> = ({accessToken}) => {
title={t('wallet.recoveryPhrase')}
description={t('wallet.recoveryPhraseEnabled')}
icon={<GppGoodOutlinedIcon className={classes.iconEnabled} />}
/>
>
<Button
onClick={handleChangePasswordClicked}
variant="contained"
size="small"
>
{t('wallet.changeRecoveryPhrase')}
</Button>
</WalletPreference>
<WalletPreference
title={t('wallet.recoveryKit')}
description={t('wallet.recoveryKitManage')}
Expand Down Expand Up @@ -152,6 +164,17 @@ const SecurityCenterModal: React.FC<Props> = ({accessToken}) => {
<RecoverySetupModal accessToken={accessToken} />
</Modal>
)}
{isPasswordModalOpen && (
<Modal
title={t('wallet.changeRecoveryPhrase')}
open={isPasswordModalOpen}
fullScreen={false}
titleStyle={classes.modalTitleStyle}
onClose={() => setIsPasswordModalOpen(false)}
>
<ChangePasswordModal accessToken={accessToken} />
</Modal>
)}
{isMfaModalOpen && (
<TwoFactorModal
emailAddress="[email protected]"
Expand Down
Loading

0 comments on commit 1082d95

Please sign in to comment.