first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -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);
}

View 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 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);
}

View 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);
}

View 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 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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}