diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
index 7a493d5b0..fa5d223d8 100644
--- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
+++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
@@ -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 = () => {
flushSync(() => {
onOpenChange(false);
@@ -273,7 +248,36 @@ export const EnableAuthenticatorAppDialog = ({
Token
-
+ {
+ 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
+ />
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index ec690a568..b182d5d76 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -31,6 +31,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/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';
const ERROR_MESSAGES: Partial> = {
@@ -72,6 +73,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
>('totp');
+ const [state, setState] = useState('input');
const form = useForm({
values: {
@@ -151,18 +153,24 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
title: 'Unable to sign in',
description: errorMessage ?? 'An unknown error occurred',
});
-
- return;
}
+ setState('success');
+ console.log(result);
+
if (!result?.url) {
throw new Error('An unknown error occurred');
}
window.location.href = result.url;
} catch (err) {
+ form.setError('totpCode', {
+ message: 'invalid totp',
+ });
+
toast({
title: 'An unknown error occurred',
+ variant: 'destructive',
description:
'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}
onOpenChange={onCloseTwoFactorAuthenticationDialog}
>
-
+
Two-Factor Authentication
@@ -265,13 +273,38 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
(
+ render={({ field: _field }) => (
Authentication Token
-
+ {
+ 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
+ />
-
)}
/>
diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx
new file mode 100644
index 000000000..b29d64376
--- /dev/null
+++ b/packages/ui/primitives/pin-input.tsx
@@ -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(null);
+ const focused = useIsFocused(inputRef);
+
+ const width = getSegmentCssWidth('14px');
+
+ return (
+ {
+ 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 (
+
+ );
+ }}
+ />
+ );
+};
+
+export { PinInput };
diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css
index fe7bfa087..15c6a2f8d 100644
--- a/packages/ui/styles/theme.css
+++ b/packages/ui/styles/theme.css
@@ -114,3 +114,25 @@
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
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;
+ }
+}