From 897f0dabdefeebedb93f0a32be500d22d8c1fde0 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 14:21:40 +0000 Subject: [PATCH 1/6] feat: 2fa pin input component --- apps/web/package.json | 2 + .../2fa/enable-authenticator-app-dialog.tsx | 44 +++++++++++++++---- package-lock.json | 32 ++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index fd4faa0c1..8656d5092 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", + "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -47,6 +48,7 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", + "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { 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 7a181c4cc..7a493d5b0 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 @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -27,8 +27,8 @@ import { FormLabel, FormMessage, } 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'; import { RecoveryCodeList } from './recovery-code-list'; @@ -54,6 +54,7 @@ export const EnableAuthenticatorAppDialog = ({ open, onOpenChange, }: EnableAuthenticatorAppDialogProps) => { + const [state, setState] = useState('input'); const router = useRouter(); const { toast } = useToast(); @@ -119,13 +120,15 @@ export const EnableAuthenticatorAppDialog = ({ token, }: TEnableTwoFactorAuthenticationForm) => { try { - await enableTwoFactorAuthentication({ code: token }); + const enabled2fa = await enableTwoFactorAuthentication({ code: token }); toast({ title: 'Two-factor authentication enabled', description: 'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.', }); + + return enabled2fa; } catch (_err) { toast({ title: 'Unable to setup two-factor authentication', @@ -136,6 +139,31 @@ 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); @@ -146,7 +174,7 @@ export const EnableAuthenticatorAppDialog = ({ return ( - + Enable Authenticator App @@ -241,18 +269,18 @@ export const EnableAuthenticatorAppDialog = ({ ( + render={({ field: _field }) => ( Token - + )} /> - + {/* @@ -260,7 +288,7 @@ export const EnableAuthenticatorAppDialog = ({ - + */} )) diff --git a/package-lock.json b/package-lock.json index 3c136e801..803a68344 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,7 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", + "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -163,6 +164,7 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", + "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { @@ -15726,6 +15728,18 @@ "node": ">= 0.8" } }, + "node_modules/rci": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rci/-/rci-0.1.0.tgz", + "integrity": "sha512-o/elFrXXRLdYDAq/qQUFE175TqzJ5nU3MYwIwa6WOZfljNJ4akQSy1n7zA79swB696MNIFDWJs+Do0q2FBTy+Q==", + "dependencies": { + "use-code-input": "0.0.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/re-resizable": { "version": "6.9.6", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", @@ -18860,6 +18874,24 @@ } } }, + "node_modules/use-code-input": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/use-code-input/-/use-code-input-0.0.2.tgz", + "integrity": "sha512-lDIUiRca0K8sF+c/KZ9cz5g6oPqlFiTmaDgwGzg0wlNSnFAvROtweKy0XpihEWJwo2tjETtgAxIh82RVGaBFHQ==", + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/use-is-focused": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/use-is-focused/-/use-is-focused-0.0.1.tgz", + "integrity": "sha512-EXVmfDqdzUJOYukC9rBCs4TYd93lDVAL6TxegnV0+3U4cBxWxhbyt1bOm5u1ox+0MZZjamBFU/NSTLTtex2uwQ==", + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", From 345c4b8b147d418fb087d093d621b3935951d881 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 15:55:58 +0000 Subject: [PATCH 2/6] feat: use pin-input on sign in --- .../2fa/enable-authenticator-app-dialog.tsx | 56 ++++++------ apps/web/src/components/forms/signin.tsx | 45 ++++++++-- packages/ui/primitives/pin-input.tsx | 85 +++++++++++++++++++ packages/ui/styles/theme.css | 22 +++++ 4 files changed, 176 insertions(+), 32 deletions(-) create mode 100644 packages/ui/primitives/pin-input.tsx 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; + } +} From 94eee8b913b027cec90429123876c825283252ef Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 16 Feb 2024 20:49:52 +0000 Subject: [PATCH 3/6] chore: change font family --- packages/ui/primitives/pin-input.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx index b29d64376..9f579288c 100644 --- a/packages/ui/primitives/pin-input.tsx +++ b/packages/ui/primitives/pin-input.tsx @@ -30,13 +30,12 @@ const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { inputClassName="caret-transparent selection:bg-transparent ring:ring-2" autoFocus={autoFocus} length={6} - fontFamily="Inter" - fontSize="36px" + fontSize="30px" readOnly={state !== 'input'} disabled={state === 'loading'} inputRef={inputRef} padding={'14px'} - spacing={'18px'} + spacing={'24px'} spellCheck={false} inputMode="numeric" pattern="[0-9]*" @@ -69,8 +68,7 @@ const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { >
Date: Fri, 16 Feb 2024 21:20:16 +0000 Subject: [PATCH 4/6] chore: use token input on enable 2fa --- .../2fa/enable-authenticator-app-dialog.tsx | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) 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 fa5d223d8..41a5ca573 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 @@ -130,6 +130,10 @@ export const EnableAuthenticatorAppDialog = ({ return enabled2fa; } catch (_err) { + enableTwoFactorAuthenticationForm.setError('token', { + message: 'Unable to setup two-factor authentication', + }); + toast({ title: 'Unable to setup two-factor authentication', description: @@ -251,40 +255,41 @@ export const EnableAuthenticatorAppDialog = ({ { - console.log(code); - + onSubmit={async ({ code, input }) => { if (code.length === 6) { setState('loading'); + enableTwoFactorAuthenticationForm.setValue('token', code); - void onEnableTwoFactorAuthenticationFormSubmit({ token: code }).then( - (success) => { - if (success) { - setState('success'); - return; - } + await enableTwoFactorAuthenticationForm.handleSubmit( + onEnableTwoFactorAuthenticationFormSubmit, + )(); - setState('error'); + if ( + enableTwoFactorAuthenticationForm.formState.isSubmitted && + !enableTwoFactorAuthenticationForm.formState.errors.totpCode + ) { + setState('success'); + return; + } - setTimeout(() => { - setState('input'); - input.value = ''; - input.dispatchEvent(new Event('input')); - input.focus(); - }, 500); - }, - ); + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); } }} autoFocus /> - )} /> - {/* + @@ -292,7 +297,7 @@ export const EnableAuthenticatorAppDialog = ({ - */} + )) From aadb22cdbfcfddb4da3778e74429abd9c29f2522 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 24 May 2024 02:51:25 +0000 Subject: [PATCH 5/6] fix: use shadcn pin input and revert changes --- apps/web/package.json | 3 +- .../2fa/enable-authenticator-app-dialog.tsx | 51 +------ apps/web/src/components/forms/signin.tsx | 45 +----- package-lock.json | 42 ++---- packages/tailwind-config/index.cjs | 5 + packages/ui/primitives/pin-input.tsx | 136 +++++++++--------- packages/ui/styles/theme.css | 22 --- 7 files changed, 93 insertions(+), 211 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 8656d5092..6ff9979bb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@tanstack/react-query": "^4.29.5", "formidable": "^2.1.1", "framer-motion": "^10.12.8", + "input-otp": "^1.2.4", "lucide-react": "^0.279.0", "luxon": "^3.4.0", "micro": "^10.0.1", @@ -35,7 +36,6 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", - "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -48,7 +48,6 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", - "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { 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 41a5ca573..7a181c4cc 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 @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useRouter } from 'next/navigation'; @@ -27,8 +27,8 @@ import { FormLabel, FormMessage, } 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'; import { RecoveryCodeList } from './recovery-code-list'; @@ -54,7 +54,6 @@ export const EnableAuthenticatorAppDialog = ({ open, onOpenChange, }: EnableAuthenticatorAppDialogProps) => { - const [state, setState] = useState('input'); const router = useRouter(); const { toast } = useToast(); @@ -120,20 +119,14 @@ export const EnableAuthenticatorAppDialog = ({ token, }: TEnableTwoFactorAuthenticationForm) => { try { - const enabled2fa = await enableTwoFactorAuthentication({ code: token }); + await enableTwoFactorAuthentication({ code: token }); toast({ title: 'Two-factor authentication enabled', description: 'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.', }); - - return enabled2fa; } catch (_err) { - enableTwoFactorAuthenticationForm.setError('token', { - message: 'Unable to setup two-factor authentication', - }); - toast({ title: 'Unable to setup two-factor authentication', description: @@ -153,7 +146,7 @@ export const EnableAuthenticatorAppDialog = ({ return ( - + Enable Authenticator App @@ -248,43 +241,13 @@ export const EnableAuthenticatorAppDialog = ({ ( + render={({ field }) => ( Token - { - if (code.length === 6) { - setState('loading'); - enableTwoFactorAuthenticationForm.setValue('token', code); - - await enableTwoFactorAuthenticationForm.handleSubmit( - onEnableTwoFactorAuthenticationFormSubmit, - )(); - - if ( - enableTwoFactorAuthenticationForm.formState.isSubmitted && - !enableTwoFactorAuthenticationForm.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/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index b182d5d76..ec690a568 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -31,7 +31,6 @@ 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> = { @@ -73,7 +72,6 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' >('totp'); - const [state, setState] = useState('input'); const form = useForm({ values: { @@ -153,10 +151,9 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign title: 'Unable to sign in', description: errorMessage ?? 'An unknown error occurred', }); - } - setState('success'); - console.log(result); + return; + } if (!result?.url) { throw new Error('An unknown error occurred'); @@ -164,13 +161,8 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign 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.', }); @@ -262,7 +254,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign open={isTwoFactorAuthenticationDialogOpen} onOpenChange={onCloseTwoFactorAuthenticationDialog} > - + Two-Factor Authentication @@ -273,38 +265,13 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign ( + render={({ 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/package-lock.json b/package-lock.json index 803a68344..310f227ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -141,6 +141,7 @@ "@tanstack/react-query": "^4.29.5", "formidable": "^2.1.1", "framer-motion": "^10.12.8", + "input-otp": "^1.2.4", "lucide-react": "^0.279.0", "luxon": "^3.4.0", "micro": "^10.0.1", @@ -151,7 +152,6 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", - "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -164,7 +164,6 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", - "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { @@ -11750,6 +11749,15 @@ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" }, + "node_modules/input-otp": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz", + "integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/internal-slot": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", @@ -15728,18 +15736,6 @@ "node": ">= 0.8" } }, - "node_modules/rci": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/rci/-/rci-0.1.0.tgz", - "integrity": "sha512-o/elFrXXRLdYDAq/qQUFE175TqzJ5nU3MYwIwa6WOZfljNJ4akQSy1n7zA79swB696MNIFDWJs+Do0q2FBTy+Q==", - "dependencies": { - "use-code-input": "0.0.2" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, "node_modules/re-resizable": { "version": "6.9.6", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", @@ -18874,24 +18870,6 @@ } } }, - "node_modules/use-code-input": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/use-code-input/-/use-code-input-0.0.2.tgz", - "integrity": "sha512-lDIUiRca0K8sF+c/KZ9cz5g6oPqlFiTmaDgwGzg0wlNSnFAvROtweKy0XpihEWJwo2tjETtgAxIh82RVGaBFHQ==", - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/use-is-focused": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/use-is-focused/-/use-is-focused-0.0.1.tgz", - "integrity": "sha512-EXVmfDqdzUJOYukC9rBCs4TYd93lDVAL6TxegnV0+3U4cBxWxhbyt1bOm5u1ox+0MZZjamBFU/NSTLTtex2uwQ==", - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index 1564454d8..f1061b654 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -110,10 +110,15 @@ module.exports = { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: 0 }, }, + 'caret-blink': { + '0%,70%,100%': { opacity: '1' }, + '20%,50%': { opacity: '0' }, + }, }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', + 'caret-blink': 'caret-blink 1.25s ease-out infinite', }, screens: { '3xl': '1920px', diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx index 9f579288c..3c51a49c6 100644 --- a/packages/ui/primitives/pin-input.tsx +++ b/packages/ui/primitives/pin-input.tsx @@ -1,83 +1,75 @@ 'use client'; -import { useRef } from 'react'; +import * as React from 'react'; -import { CodeInput, getSegmentCssWidth } from 'rci'; -import { useIsFocused } from 'use-is-focused'; +import { cn } from '@/lib/utils'; +import { DashIcon } from '@radix-ui/react-icons'; +import { OTPInput, OTPInputContext } from 'input-otp'; -import { cn } from '@documenso/ui/lib/utils'; +const PinInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)); -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; -}; +PinInput.displayName = 'PinInput'; -const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { - const inputRef = useRef(null); - const focused = useIsFocused(inputRef); +const PinInputGroup = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ className, ...props }, ref) => ( +
+)); - const width = getSegmentCssWidth('14px'); +PinInputGroup.displayName = 'PinInputGroup'; + +const PinInputSlot = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> & { index: number } +>(({ index, className, ...props }, ref) => { + const context = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = context.slots[index]; 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 ( -
-
-
- ); - }} - /> +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
); -}; +}); -export { PinInput }; +PinInputSlot.displayName = 'PinInputSlot'; + +const PinInputSeparator = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ ...props }, ref) => ( +
+ +
+)); + +PinInputSeparator.displayName = 'PinInputSeparator'; + +export { PinInput, PinInputGroup, PinInputSlot, PinInputSeparator }; diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index 15c6a2f8d..fe7bfa087 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -114,25 +114,3 @@ .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; - } -} From 9cb80aa0bc30790eb84796375f568d7f5d7f44b2 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 24 May 2024 03:31:19 +0000 Subject: [PATCH 6/6] chore: add pin input to all 2FA components Adds the pin input to the currently used 2FA components sunsetting the standard input that was previously used. --- .../sign/[token]/document-action-auth-2fa.tsx | 12 ++++++++++-- .../forms/2fa/disable-authenticator-app-dialog.tsx | 12 ++++++++++-- .../forms/2fa/enable-authenticator-app-dialog.tsx | 12 ++++++++++-- .../forms/2fa/view-recovery-codes-dialog.tsx | 12 ++++++++++-- apps/web/src/components/forms/signin.tsx | 13 +++++++++++-- packages/ui/primitives/pin-input.tsx | 9 +++++---- 6 files changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx index 98bcacf10..5af9ced18 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx @@ -18,7 +18,7 @@ import { FormLabel, FormMessage, } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; @@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({ 2FA token - + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + diff --git a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx index c5745a499..5949d14bb 100644 --- a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -28,7 +28,7 @@ import { FormItem, FormMessage, } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZDisable2FAForm = z.object({ @@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => { render={({ field }) => ( - + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + 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 ce0b66ba4..96cbf6d1f 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 @@ -30,7 +30,7 @@ import { FormLabel, FormMessage, } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { RecoveryCodeList } from './recovery-code-list'; @@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA Token - + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 8a6177b5b..3c5c4e5ca 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -30,7 +30,7 @@ import { FormItem, FormMessage, } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { RecoveryCodeList } from './recovery-code-list'; @@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => { render={({ field }) => ( - + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 8d4dd7cd0..59b8af6c7 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -38,6 +38,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, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; const ERROR_MESSAGES: Partial> = { @@ -372,9 +373,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign name="totpCode" render={({ field }) => ( - Authentication Token + Token - + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx index 3c51a49c6..1d80dd3ad 100644 --- a/packages/ui/primitives/pin-input.tsx +++ b/packages/ui/primitives/pin-input.tsx @@ -2,9 +2,10 @@ import * as React from 'react'; -import { cn } from '@/lib/utils'; -import { DashIcon } from '@radix-ui/react-icons'; import { OTPInput, OTPInputContext } from 'input-otp'; +import { Minus } from 'lucide-react'; + +import { cn } from '../lib/utils'; const PinInput = React.forwardRef< React.ElementRef, @@ -43,7 +44,7 @@ const PinInputSlot = React.forwardRef<
>(({ ...props }, ref) => (
- +
));