pl-fe: migrate 2fa settings to tanstack query

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-06-26 12:10:48 +02:00
parent 3f1d044880
commit 847f40dfc2
9 changed files with 93 additions and 160 deletions

View File

@ -1,55 +0,0 @@
import { getClient } from '../api';
import type { PlApiClient } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
const MFA_FETCH_SUCCESS = 'MFA_FETCH_SUCCESS' as const;
const MFA_CONFIRM_SUCCESS = 'MFA_CONFIRM_SUCCESS' as const;
const MFA_DISABLE_SUCCESS = 'MFA_DISABLE_SUCCESS' as const;
const fetchMfa = () =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.mfa.getMfaSettings().then((data) => {
dispatch<MfaAction>({ type: MFA_FETCH_SUCCESS, data });
});
const fetchBackupCodes = () =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.mfa.getMfaBackupCodes();
const setupMfa = (method: 'totp') =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.mfa.getMfaSetup(method);
const confirmMfa = (method: 'totp', code: string, password: string) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.mfa.confirmMfaSetup(method, code, password).then((data) => {
dispatch<MfaAction>({ type: MFA_CONFIRM_SUCCESS, method, code });
return data;
});
const disableMfa = (method: 'totp', password: string) =>
(dispatch: AppDispatch, getState: () => RootState) =>
getClient(getState).settings.mfa.disableMfa(method, password).then((data) => {
dispatch<MfaAction>({ type: MFA_DISABLE_SUCCESS, method });
return data;
});
type MfaAction =
| { type: typeof MFA_FETCH_SUCCESS; data: Awaited<ReturnType<(InstanceType<typeof PlApiClient>)['settings']['mfa']['getMfaSettings']>> }
| { type: typeof MFA_CONFIRM_SUCCESS; method: 'totp'; code: string }
| { type: typeof MFA_DISABLE_SUCCESS; method: 'totp' }
export {
MFA_FETCH_SUCCESS,
MFA_CONFIRM_SUCCESS,
MFA_DISABLE_SUCCESS,
fetchMfa,
fetchBackupCodes,
setupMfa,
confirmMfa,
disableMfa,
type MfaAction,
};

View File

@ -1,12 +1,10 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { fetchMfa } from 'pl-fe/actions/mfa';
import Column from 'pl-fe/components/ui/column';
import Stack from 'pl-fe/components/ui/stack';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useMfaConfig } from 'pl-fe/queries/settings/use-mfa';
import DisableOtpForm from './mfa/disable-otp-form';
import EnableOtpForm from './mfa/enable-otp-form';
@ -18,24 +16,19 @@ const messages = defineMessages({
const MfaForm: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const [displayOtpForm, setDisplayOtpForm] = useState<boolean>(false);
useEffect(() => {
dispatch(fetchMfa());
}, []);
const { data: mfa } = useMfaConfig();
const handleSetupProceedClick = (event: React.MouseEvent) => {
event.preventDefault();
setDisplayOtpForm(true);
};
const mfa = useAppSelector((state) => state.security.mfa);
return (
<Column label={intl.formatMessage(messages.heading)}>
{mfa.settings.totp ? (
{mfa?.settings.totp ? (
<DisableOtpForm />
) : (
<Stack space={4}>

View File

@ -2,7 +2,6 @@ import React, { useState, useCallback } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { disableMfa } from 'pl-fe/actions/mfa';
import Button from 'pl-fe/components/ui/button';
import Form from 'pl-fe/components/ui/form';
import FormActions from 'pl-fe/components/ui/form-actions';
@ -11,6 +10,7 @@ import Input from 'pl-fe/components/ui/input';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useDisableMfa } from 'pl-fe/queries/settings/use-mfa';
import toast from 'pl-fe/toast';
const messages = defineMessages({
@ -21,22 +21,23 @@ const messages = defineMessages({
});
const DisableOtpForm: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [password, setPassword] = useState('');
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const { mutate: disableMfa, isPending } = useDisableMfa();
const handleSubmit = useCallback(() => {
setIsLoading(true);
dispatch(disableMfa('totp', password)).then(() => {
toast.success(intl.formatMessage(messages.mfaDisableSuccess));
history.push('../auth/edit');
}).finally(() => {
setIsLoading(false);
}).catch(() => {
toast.error(intl.formatMessage(messages.disableFail));
disableMfa(password, {
onSuccess: () => {
toast.success(intl.formatMessage(messages.mfaDisableSuccess));
history.push('../auth/edit');
},
onError: () => {
toast.error(intl.formatMessage(messages.disableFail));
},
});
}, [password, dispatch, intl]);
@ -65,7 +66,7 @@ const DisableOtpForm: React.FC = () => {
placeholder={intl.formatMessage(messages.passwordPlaceholder)}
name='password'
onChange={handleInputChange}
disabled={isLoading}
disabled={isPending}
value={password}
required
/>
@ -73,7 +74,7 @@ const DisableOtpForm: React.FC = () => {
<FormActions>
<Button
disabled={isLoading}
disabled={isPending}
theme='danger'
type='submit'
text={intl.formatMessage(messages.mfa_setup_disable_button)}

View File

@ -2,13 +2,12 @@ import React, { useState, useEffect } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { fetchBackupCodes } from 'pl-fe/actions/mfa';
import Button from 'pl-fe/components/ui/button';
import FormActions from 'pl-fe/components/ui/form-actions';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useClient } from 'pl-fe/hooks/use-client';
import toast from 'pl-fe/toast';
const messages = defineMessages({
@ -23,14 +22,14 @@ interface IEnableOtpForm {
}
const EnableOtpForm: React.FC<IEnableOtpForm> = ({ displayOtpForm, handleSetupProceedClick }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const history = useHistory();
const client = useClient();
const [backupCodes, setBackupCodes] = useState<Array<string>>([]);
useEffect(() => {
dispatch(fetchBackupCodes()).then(({ codes: backupCodes }) => {
client.settings.mfa.getMfaBackupCodes().then(({ codes: backupCodes }) => {
setBackupCodes(backupCodes);
})
.catch(() => {

View File

@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { setupMfa, confirmMfa } from 'pl-fe/actions/mfa';
import Button from 'pl-fe/components/ui/button';
import Form from 'pl-fe/components/ui/form';
import FormActions from 'pl-fe/components/ui/form-actions';
@ -11,8 +10,9 @@ import FormGroup from 'pl-fe/components/ui/form-group';
import Input from 'pl-fe/components/ui/input';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useClient } from 'pl-fe/hooks/use-client';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useConfirmMfa } from 'pl-fe/queries/settings/use-mfa';
import toast from 'pl-fe/toast';
const messages = defineMessages({
@ -28,19 +28,20 @@ const messages = defineMessages({
const OtpConfirmForm: React.FC = () => {
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const features = useFeatures();
const client = useClient();
const [state, setState] = useState<{ password: string; isLoading: boolean; code: string; qrCodeURI: string; confirmKey: string }>({
const { mutate: confirmMfa, isPending } = useConfirmMfa();
const [state, setState] = useState<{ password: string; code: string; qrCodeURI: string; confirmKey: string }>({
password: '',
isLoading: false,
code: '',
qrCodeURI: '',
confirmKey: '',
});
useEffect(() => {
dispatch(setupMfa('totp')).then((data) => {
client.settings.mfa.getMfaSetup('totp').then((data) => {
setState((prevState) => ({ ...prevState, qrCodeURI: data.provisioning_uri, confirmKey: data.key }));
}).catch(() => {
toast.error(intl.formatMessage(messages.qrFail));
@ -56,12 +57,14 @@ const OtpConfirmForm: React.FC = () => {
const handleSubmit = (e: React.FormEvent) => {
setState((prevState) => ({ ...prevState, isLoading: true }));
dispatch(confirmMfa('totp', state.code, state.password)).then((r) => {
toast.success(intl.formatMessage(messages.mfaConfirmSuccess));
history.push('../auth/edit');
}).catch(() => {
toast.error(intl.formatMessage(messages.confirmFail));
setState((prevState) => ({ ...prevState, isLoading: false }));
confirmMfa(state, {
onSuccess: () => {
toast.success(intl.formatMessage(messages.mfaConfirmSuccess));
history.push('../auth/edit');
},
onError: () => {
toast.error(intl.formatMessage(messages.confirmFail));
},
});
e.preventDefault();
@ -96,7 +99,7 @@ const OtpConfirmForm: React.FC = () => {
placeholder={intl.formatMessage(messages.codePlaceholder)}
onChange={handleInputChange}
autoComplete='off'
disabled={state.isLoading}
disabled={isPending}
value={state.code}
type='text'
required
@ -113,7 +116,7 @@ const OtpConfirmForm: React.FC = () => {
name='password'
placeholder={intl.formatMessage(messages.passwordPlaceholder)}
onChange={handleInputChange}
disabled={state.isLoading}
disabled={isPending}
value={state.password}
required
/>
@ -126,14 +129,14 @@ const OtpConfirmForm: React.FC = () => {
theme='tertiary'
text={intl.formatMessage(messages.mfaCancelButton)}
onClick={() => history.push('../auth/edit')}
disabled={state.isLoading}
disabled={isPending}
/>
<Button
type='submit'
theme='primary'
text={intl.formatMessage(messages.mfaSetupConfirmButton)}
disabled={state.isLoading}
disabled={isPending}
/>
</FormActions>
</Form>

View File

@ -1,17 +1,15 @@
import React, { useEffect } from 'react';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchMfa } from 'pl-fe/actions/mfa';
import List, { ListItem } from 'pl-fe/components/list';
import Card, { CardBody, CardHeader, CardTitle } from 'pl-fe/components/ui/card';
import Column from 'pl-fe/components/ui/column';
import Text from 'pl-fe/components/ui/text';
import Preferences from 'pl-fe/features/preferences';
import MessagesSettings from 'pl-fe/features/settings/components/messages-settings';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import { useMfaConfig } from 'pl-fe/queries/settings/use-mfa';
const messages = defineMessages({
accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
@ -44,18 +42,13 @@ const messages = defineMessages({
/** User settings page. */
const SettingsPage = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const mfa = useAppSelector((state) => state.security.mfa);
const { data: mfa } = useMfaConfig();
const features = useFeatures();
const { account } = useOwnAccount();
const isMfaEnabled = mfa.settings?.totp;
useEffect(() => {
if (features.manageMfa) dispatch(fetchMfa());
}, [dispatch]);
const isMfaEnabled = mfa?.settings.totp;
if (!account) return null;

View File

@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'pl-fe/hooks/use-client';
const useMfaConfig = () => {
const client = useClient();
return useQuery({
queryKey: ['settings', 'mfa'],
queryFn: () => client.settings.mfa.getMfaSettings(),
});
};
const useConfirmMfa = () => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['settings', 'mfa'],
mutationFn: ({ code, password }: { code: string; password: string }) => client.settings.mfa.confirmMfaSetup('totp', code, password),
onSuccess: () => {
queryClient.setQueryData(['settings', 'mfa'], {
settings: {
enabled: true,
totp: true,
},
});
},
});
};
const useDisableMfa = () => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['settings', 'mfa'],
mutationFn: (password: string) => client.settings.mfa.disableMfa('totp', password),
onSuccess: () => {
queryClient.setQueryData(['settings', 'mfa'], {
settings: {
enabled: false,
totp: false,
},
});
},
});
};
export { useMfaConfig, useConfirmMfa, useDisableMfa };

View File

@ -25,7 +25,6 @@ import pending_statuses from './pending-statuses';
import plfe from './pl-fe';
import polls from './polls';
import push_notifications from './push-notifications';
import security from './security';
import shoutbox from './shoutbox';
import status_lists from './status-lists';
import statuses from './statuses';
@ -54,7 +53,6 @@ const reducers = {
plfe,
polls,
push_notifications,
security,
shoutbox,
status_lists,
statuses,

View File

@ -1,49 +0,0 @@
import { create } from 'mutative';
import {
MFA_FETCH_SUCCESS,
MFA_CONFIRM_SUCCESS,
MFA_DISABLE_SUCCESS,
type MfaAction,
} from '../actions/mfa';
interface State {
mfa: {
settings: Record<string, boolean>;
};
}
const initialState: State = {
mfa: {
settings: {
totp: false,
},
},
};
const importMfa = (state: State, data: any) => state.mfa = data;
const enableMfa = (state: State, method: string) => state.mfa.settings = { ...state.mfa.settings, [method]: true };
const disableMfa = (state: State, method: string) => state.mfa.settings = { ...state.mfa.settings, [method]: false };
const security = (state = initialState, action: MfaAction) => {
switch (action.type) {
case MFA_FETCH_SUCCESS:
return create(state, (draft) => {
importMfa(draft, action.data);
});
case MFA_CONFIRM_SUCCESS:
return create(state, (draft) => {
enableMfa(draft, action.method);
});
case MFA_DISABLE_SUCCESS:
return create(state, (draft) => {
disableMfa(draft, action.method);
});
default:
return state;
}
};
export { security as default };