mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 00:32:43 +10:00
feat: use pin-input on sign in
This commit is contained in:
@ -139,31 +139,6 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const onPinInputChange = ({ currentTarget: input }: any) => {
|
|
||||||
input.value = input.value.replace(/\D+/g, '');
|
|
||||||
|
|
||||||
if (input.value.length === 6) {
|
|
||||||
setState('loading');
|
|
||||||
|
|
||||||
void onEnableTwoFactorAuthenticationFormSubmit({ token: input.value }).then((success) => {
|
|
||||||
if (success) {
|
|
||||||
setState('success');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState('error');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setState('input');
|
|
||||||
input.value = '';
|
|
||||||
input.dispatchEvent(new Event('input'));
|
|
||||||
input.focus();
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCompleteClick = () => {
|
const onCompleteClick = () => {
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@ -273,7 +248,36 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PinInput id="remix" state={state} onChange={onPinInputChange} autoFocus />
|
<PinInput
|
||||||
|
id="enable-2fa-pin-input"
|
||||||
|
state={state}
|
||||||
|
onSubmit={({ code, input }) => {
|
||||||
|
console.log(code);
|
||||||
|
|
||||||
|
if (code.length === 6) {
|
||||||
|
setState('loading');
|
||||||
|
|
||||||
|
void onEnableTwoFactorAuthenticationFormSubmit({ token: code }).then(
|
||||||
|
(success) => {
|
||||||
|
if (success) {
|
||||||
|
setState('success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('error');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setState('input');
|
||||||
|
input.value = '';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
input.focus();
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||||
|
import { PinInput, type PinInputState } from '@documenso/ui/primitives/pin-input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||||
@ -72,6 +73,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
const [state, setState] = useState<PinInputState>('input');
|
||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
@ -151,18 +153,24 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
title: 'Unable to sign in',
|
title: 'Unable to sign in',
|
||||||
description: errorMessage ?? 'An unknown error occurred',
|
description: errorMessage ?? 'An unknown error occurred',
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState('success');
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
if (!result?.url) {
|
if (!result?.url) {
|
||||||
throw new Error('An unknown error occurred');
|
throw new Error('An unknown error occurred');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = result.url;
|
window.location.href = result.url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
form.setError('totpCode', {
|
||||||
|
message: 'invalid totp',
|
||||||
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
description:
|
description:
|
||||||
'We encountered an unknown error while attempting to sign you In. Please try again later.',
|
'We encountered an unknown error while attempting to sign you In. Please try again later.',
|
||||||
});
|
});
|
||||||
@ -254,7 +262,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
open={isTwoFactorAuthenticationDialogOpen}
|
open={isTwoFactorAuthenticationDialogOpen}
|
||||||
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent className="w-full max-w-md md:max-w-md lg:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -265,13 +273,38 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="totpCode"
|
name="totpCode"
|
||||||
render={({ field }) => (
|
render={({ field: _field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Authentication Token</FormLabel>
|
<FormLabel>Authentication Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} />
|
<PinInput
|
||||||
|
id="verify-2fa-signin-pin-input"
|
||||||
|
state={state}
|
||||||
|
onSubmit={async ({ code, input }) => {
|
||||||
|
if (code.length === 6) {
|
||||||
|
setState('loading');
|
||||||
|
form.setValue('totpCode', code);
|
||||||
|
|
||||||
|
await form.handleSubmit(onFormSubmit)();
|
||||||
|
|
||||||
|
if (form.formState.isSubmitted && !form.formState.errors.totpCode) {
|
||||||
|
setState('success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('error');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setState('input');
|
||||||
|
input.value = '';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
input.focus();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
85
packages/ui/primitives/pin-input.tsx
Normal file
85
packages/ui/primitives/pin-input.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
import { CodeInput, getSegmentCssWidth } from 'rci';
|
||||||
|
import { useIsFocused } from 'use-is-focused';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
export type PinInputState = 'input' | 'loading' | 'error' | 'success';
|
||||||
|
export type PinInputProps = {
|
||||||
|
id: string;
|
||||||
|
state: PinInputState;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onSubmit({ code, input }: { code: string; input: EventTarget & HTMLInputElement }): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const focused = useIsFocused(inputRef);
|
||||||
|
|
||||||
|
const width = getSegmentCssWidth('14px');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeInput
|
||||||
|
id={id}
|
||||||
|
className={cn({
|
||||||
|
'motion-safe:animate-[shake_0.15s_ease-in-out_0s_2]': state === 'error',
|
||||||
|
})}
|
||||||
|
inputClassName="caret-transparent selection:bg-transparent ring:ring-2"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
length={6}
|
||||||
|
fontFamily="Inter"
|
||||||
|
fontSize="36px"
|
||||||
|
readOnly={state !== 'input'}
|
||||||
|
disabled={state === 'loading'}
|
||||||
|
inputRef={inputRef}
|
||||||
|
padding={'14px'}
|
||||||
|
spacing={'18px'}
|
||||||
|
spellCheck={false}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
onChange={({ currentTarget: input }) => {
|
||||||
|
input.value = input.value.replace(/\D+/g, '');
|
||||||
|
onSubmit({ code: input.value, input });
|
||||||
|
}}
|
||||||
|
renderSegment={(segment) => {
|
||||||
|
const isCaret = focused && segment.state === 'cursor';
|
||||||
|
const isSelection = focused && segment.state === 'selected';
|
||||||
|
const isLoading = state === 'loading';
|
||||||
|
const isSuccess = state === 'success';
|
||||||
|
const isError = state === 'error';
|
||||||
|
const isActive = isSuccess || isError || isSelection || isCaret;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={segment.index}
|
||||||
|
data-state={state}
|
||||||
|
className={cn(
|
||||||
|
'border-input flex appearance-none rounded-md border [--segment-color:#a2e771] data-[state="error"]:[--segment-color:#dc2626] data-[state="success"]:[--segment-color:#a2e771]',
|
||||||
|
{
|
||||||
|
'shadow-[var(--segment-color)_0_0_0_1px] data-[state]:border-[var(--segment-color)]':
|
||||||
|
isActive,
|
||||||
|
'animate-[pulse-border_1s_ease-in-out_0s_infinite]': isLoading,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn({
|
||||||
|
'm-[5px] flex-1 rounded-sm bg-[var(--segment-color)] opacity-[0.15625]':
|
||||||
|
isSelection,
|
||||||
|
'mx-auto my-2 flex-[0_0_2px] animate-[blink-caret_1.2s_step-end_infinite] justify-self-center bg-black':
|
||||||
|
isCaret,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PinInput };
|
||||||
@ -114,3 +114,25 @@
|
|||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgb(100 116 139 / 0.5);
|
background: rgb(100 116 139 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes blink-caret {
|
||||||
|
50% {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
25% {
|
||||||
|
transform: translateX(10px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-border {
|
||||||
|
50% {
|
||||||
|
border-color: var(--segment-color);
|
||||||
|
box-shadow: var(--segment-color) 0 0 0 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user