@@ -0,0 +1,26 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export interface ConfirmTwoFactorPayload {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function useConfirmTwoFactor(
|
||||
form: UseFormReturn<ConfirmTwoFactorPayload>,
|
||||
) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: ConfirmTwoFactorPayload) => confirm(payload),
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function confirm(payload: ConfirmTwoFactorPayload): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/user/confirmed-two-factor-authentication', payload)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useDisableTwoFactor() {
|
||||
return useMutation({
|
||||
mutationFn: disable,
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function disable(): Promise<Response> {
|
||||
return apiClient
|
||||
.delete('auth/user/two-factor-authentication')
|
||||
.then(response => response.data);
|
||||
}
|
||||
19
common/resources/client/auth/ui/two-factor/requests/use-enable-two-factor.ts
Executable file
19
common/resources/client/auth/ui/two-factor/requests/use-enable-two-factor.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useEnableTwoFactor() {
|
||||
return useMutation({
|
||||
mutationFn: enable,
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function enable(): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/user/two-factor-authentication')
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useRegenerateTwoFactorCodes() {
|
||||
return useMutation({
|
||||
mutationFn: () => regenerate(),
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function regenerate(): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/user/two-factor-recovery-codes')
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {useHandleLoginSuccess} from '@common/auth/requests/use-login';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
bootstrapData: string;
|
||||
two_factor: false;
|
||||
}
|
||||
|
||||
export interface TwoFactorChallengePayload {
|
||||
code?: string;
|
||||
recovery_code?: string;
|
||||
}
|
||||
export function useTwoFactorChallenge(
|
||||
form: UseFormReturn<TwoFactorChallengePayload>,
|
||||
) {
|
||||
const handleSuccess = useHandleLoginSuccess();
|
||||
return useMutation({
|
||||
mutationFn: (payload: TwoFactorChallengePayload) =>
|
||||
completeChallenge(payload),
|
||||
onSuccess: response => {
|
||||
handleSuccess(response);
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function completeChallenge(
|
||||
payload: TwoFactorChallengePayload,
|
||||
): Promise<Response> {
|
||||
return apiClient
|
||||
.post('auth/two-factor-challenge', payload)
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
svg: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export function useTwoFactorQrCode() {
|
||||
return useQuery({
|
||||
queryKey: ['two-factor-qr-code'],
|
||||
queryFn: () => fetchCode(),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchCode(): Promise<Response> {
|
||||
return apiClient
|
||||
.get('auth/user/two-factor/qr-code')
|
||||
.then(response => response.data);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {useState} from 'react';
|
||||
import {User} from '@common/auth/user';
|
||||
import {TwoFactorDisabledStep} from '@common/auth/ui/two-factor/stepper/two-factor-disabled-step';
|
||||
import {TwoFactorConfirmationStep} from '@common/auth/ui/two-factor/stepper/two-factor-confirmation-step';
|
||||
import {TwoFactorEnabledStep} from '@common/auth/ui/two-factor/stepper/two-factor-enabled-step';
|
||||
|
||||
enum Status {
|
||||
Disabled,
|
||||
WaitingForConfirmation,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
export function TwoFactorStepper({user}: Props) {
|
||||
const [status, setStatus] = useState<Status>(getStatus(user));
|
||||
switch (status) {
|
||||
case Status.Disabled:
|
||||
return (
|
||||
<TwoFactorDisabledStep
|
||||
onEnabled={() => setStatus(Status.WaitingForConfirmation)}
|
||||
/>
|
||||
);
|
||||
case Status.WaitingForConfirmation:
|
||||
return (
|
||||
<TwoFactorConfirmationStep
|
||||
onCancel={() => {
|
||||
setStatus(Status.Disabled);
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
setStatus(Status.Enabled);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case Status.Enabled:
|
||||
return (
|
||||
<TwoFactorEnabledStep
|
||||
user={user}
|
||||
onDisabled={() => setStatus(Status.Disabled)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus(user: User): Status {
|
||||
if (user.two_factor_confirmed_at) {
|
||||
return Status.Enabled;
|
||||
} else if (user.two_factor_recovery_codes) {
|
||||
return Status.WaitingForConfirmation;
|
||||
}
|
||||
return Status.Disabled;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {ReactNode} from 'react';
|
||||
import {useTwoFactorQrCode} from '@common/auth/ui/two-factor/requests/use-two-factor-qr-code';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {
|
||||
ConfirmTwoFactorPayload,
|
||||
useConfirmTwoFactor,
|
||||
} from '@common/auth/ui/two-factor/requests/use-confirm-two-factor';
|
||||
import {TwoFactorStepperLayout} from '@common/auth/ui/two-factor/stepper/two-factor-stepper-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {useDisableTwoFactor} from '@common/auth/ui/two-factor/requests/use-disable-two-factor';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onConfirmed: () => void;
|
||||
}
|
||||
export function TwoFactorConfirmationStep(props: Props) {
|
||||
const {data} = useTwoFactorQrCode();
|
||||
|
||||
return (
|
||||
<TwoFactorStepperLayout
|
||||
title={<Trans message="Finish enabling two factor authentication." />}
|
||||
description={
|
||||
<Trans message="To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code." />
|
||||
}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{!data ? (
|
||||
<QrCodeLayout
|
||||
animationKey="svg-skeleton"
|
||||
svg={<Skeleton variant="rect" size="w-full h-full" />}
|
||||
secret={<Skeleton variant="text" className="max-w-224" />}
|
||||
/>
|
||||
) : (
|
||||
<QrCodeLayout
|
||||
animationKey="real-svg"
|
||||
svg={<div dangerouslySetInnerHTML={{__html: data.svg}} />}
|
||||
secret={
|
||||
<Trans message="Setup key: :key" values={{key: data.secret}} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<CodeForm {...props} />
|
||||
</TwoFactorStepperLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeForm({onCancel, onConfirmed}: Props) {
|
||||
const form = useForm<ConfirmTwoFactorPayload>();
|
||||
const confirmTwoFactor = useConfirmTwoFactor(form);
|
||||
const disableTwoFactor = useDisableTwoFactor();
|
||||
const {withConfirmedPassword, isLoading: confirmPasswordIsLoading} =
|
||||
usePasswordConfirmedAction();
|
||||
const isLoading =
|
||||
confirmTwoFactor.isPending ||
|
||||
disableTwoFactor.isPending ||
|
||||
confirmPasswordIsLoading;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={values =>
|
||||
withConfirmedPassword(() => {
|
||||
confirmTwoFactor.mutate(values, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
onConfirmed();
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
<FormTextField
|
||||
required
|
||||
name="code"
|
||||
label={<Trans message="Code" />}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-12 mt-24">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
disableTwoFactor.mutate(undefined, {onSuccess: onCancel});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trans message="Confirm" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface QrCodeLayoutProps {
|
||||
animationKey: string;
|
||||
svg: ReactNode;
|
||||
secret: ReactNode;
|
||||
}
|
||||
function QrCodeLayout({animationKey, svg, secret}: QrCodeLayoutProps) {
|
||||
return (
|
||||
<m.div key={animationKey} {...opacityAnimation}>
|
||||
<div className="w-192 h-192 mb-16">{svg}</div>
|
||||
<div className="text-sm font-medium mb-16">{secret}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useEnableTwoFactor} from '@common/auth/ui/two-factor/requests/use-enable-two-factor';
|
||||
import {TwoFactorStepperLayout} from '@common/auth/ui/two-factor/stepper/two-factor-stepper-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
|
||||
interface Props {
|
||||
onEnabled: () => void;
|
||||
}
|
||||
export function TwoFactorDisabledStep({onEnabled}: Props) {
|
||||
const enableTwoFactor = useEnableTwoFactor();
|
||||
const {withConfirmedPassword, isLoading: confirmPasswordIsLoading} =
|
||||
usePasswordConfirmedAction();
|
||||
const isLoading = enableTwoFactor.isPending || confirmPasswordIsLoading;
|
||||
|
||||
return (
|
||||
<TwoFactorStepperLayout
|
||||
title={
|
||||
<Trans message="You have not enabled two factor authentication." />
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
enableTwoFactor.mutate(undefined, {
|
||||
onSuccess: onEnabled,
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Enable" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {User} from '@common/auth/user';
|
||||
import {useDisableTwoFactor} from '@common/auth/ui/two-factor/requests/use-disable-two-factor';
|
||||
import {useRegenerateTwoFactorCodes} from '@common/auth/ui/two-factor/requests/use-regenerate-two-factor-codes';
|
||||
import {Fragment} from 'react';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TwoFactorStepperLayout} from '@common/auth/ui/two-factor/stepper/two-factor-stepper-layout';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {usePasswordConfirmedAction} from '@common/auth/ui/confirm-password/use-password-confirmed-action';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
onDisabled: () => void;
|
||||
}
|
||||
export function TwoFactorEnabledStep({user, onDisabled}: Props) {
|
||||
const disableTwoFactor = useDisableTwoFactor();
|
||||
const regenerateCodes = useRegenerateTwoFactorCodes();
|
||||
const {withConfirmedPassword, isLoading: confirmPasswordIsLoading} =
|
||||
usePasswordConfirmedAction();
|
||||
const isLoading =
|
||||
disableTwoFactor.isPending ||
|
||||
regenerateCodes.isPending ||
|
||||
confirmPasswordIsLoading;
|
||||
|
||||
const actions = (
|
||||
<Fragment>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
withConfirmedPassword(() => {
|
||||
regenerateCodes.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['users']});
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="mr-12"
|
||||
>
|
||||
<Trans message="Regenerate recovery codes" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="danger"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
withConfirmedPassword(() => {
|
||||
disableTwoFactor.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast(message('Two factor authentication has been disabled.'));
|
||||
onDisabled();
|
||||
},
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Disable" />
|
||||
</Button>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<TwoFactorStepperLayout
|
||||
title={<Trans message="You have enabled two factor authentication." />}
|
||||
description={
|
||||
<Trans message="Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost." />
|
||||
}
|
||||
actions={actions}
|
||||
>
|
||||
<div className="bg-alt p-14 font-mono text-sm mb-16 rounded">
|
||||
{user.two_factor_recovery_codes?.map(code => (
|
||||
<div className="mb-4" key={code}>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TwoFactorStepperLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
|
||||
interface Props {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
export function TwoFactorStepperLayout({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: Props) {
|
||||
if (!subtitle) {
|
||||
subtitle = (
|
||||
<Trans message="When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application." />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="text-base font-medium mb-16">{title}</div>
|
||||
<div className="text-sm mb-24">{subtitle}</div>
|
||||
<p className="text-sm font-medium my-16">{description}</p>
|
||||
{children}
|
||||
<div className="flex items-center gap-12">{actions}</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
84
common/resources/client/auth/ui/two-factor/two-factor-challenge-page.tsx
Executable file
84
common/resources/client/auth/ui/two-factor/two-factor-challenge-page.tsx
Executable file
@@ -0,0 +1,84 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';
|
||||
import {Button} from '../../../ui/buttons/button';
|
||||
import {Form} from '../../../ui/forms/form';
|
||||
import {AuthLayout} from '../auth-layout/auth-layout';
|
||||
import {Trans} from '../../../i18n/trans';
|
||||
import {StaticPageTitle} from '../../../seo/static-page-title';
|
||||
import {
|
||||
TwoFactorChallengePayload,
|
||||
useTwoFactorChallenge,
|
||||
} from '@common/auth/ui/two-factor/requests/use-two-factor-challenge';
|
||||
import {useState} from 'react';
|
||||
|
||||
export function TwoFactorChallengePage() {
|
||||
const [usingRecoveryCode, setUsingRecoveryCode] = useState(false);
|
||||
|
||||
const form = useForm<TwoFactorChallengePayload>();
|
||||
const completeChallenge = useTwoFactorChallenge(form);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Two factor authentication" />
|
||||
</StaticPageTitle>
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={payload => {
|
||||
completeChallenge.mutate(payload);
|
||||
}}
|
||||
>
|
||||
<div className="mb-32 text-sm">
|
||||
<Trans message="Confirm access to your account by entering the authentication code provided by your authenticator application." />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
{usingRecoveryCode ? (
|
||||
<FormTextField
|
||||
name="recovery_code"
|
||||
minLength={21}
|
||||
maxLength={21}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
label={<Trans message="Recovery code" />}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<FormTextField
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
label={<Trans message="Code" />}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-32">
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={() => setUsingRecoveryCode(!usingRecoveryCode)}
|
||||
>
|
||||
<Trans message="Use recovery code instead" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className="block w-full"
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={completeChallenge.isPending}
|
||||
>
|
||||
<Trans message="Continue" />
|
||||
</Button>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user