mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
refactor(signing-2fa): simplify server-side and UI code for external 2FA
- Extract throwVerificationError helper in verify-signing-two-factor-token.ts - Extract throwIssuanceDenied helper in issue-signing-two-factor-token.ts - Eliminate duplicated attemptsRemaining state in UI component - Use imported SIGNING_2FA_VERIFY_REASON_CODES constants - Add statusQuery.refetch() after failed verify for single source of truth - Fix TypeScript control flow with explicit returns after throws
This commit is contained in:
+17
-10
@@ -21,6 +21,7 @@ import {
|
||||
|
||||
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
|
||||
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
|
||||
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
|
||||
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
|
||||
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
@@ -69,15 +70,8 @@ export const DocumentSigningAuthDialog = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset selected auth type when dialog closes
|
||||
if (!value) {
|
||||
setSelectedAuthType(() => {
|
||||
if (validAuthTypes.length === 1) {
|
||||
return validAuthTypes[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
|
||||
}
|
||||
|
||||
onOpenChange(value);
|
||||
@@ -123,7 +117,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
{/* Show chooser if no auth type is selected and there are multiple options */}
|
||||
{!selectedAuthType && validAuthTypes.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Choose your preferred authentication method:</Trans>
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
@@ -141,11 +135,14 @@ export const DocumentSigningAuthDialog = ({
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
|
||||
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
|
||||
<Trans>Verification code</Trans>
|
||||
))
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{match(authType)
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => (
|
||||
@@ -154,6 +151,9 @@ export const DocumentSigningAuthDialog = ({
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => (
|
||||
<Trans>Enter your 2FA code</Trans>
|
||||
))
|
||||
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
|
||||
<Trans>Enter the verification code provided to you</Trans>
|
||||
))
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
@@ -197,6 +197,13 @@ export const DocumentSigningAuthDialog = ({
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
|
||||
<DocumentSigningAuthExternal2FA
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { SIGNING_2FA_VERIFY_REASON_CODES } from '@documenso/lib/server-only/signing-2fa/verify-signing-two-factor-token';
|
||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningAuthExternal2FAProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const ZExternal2FAFormSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.length(6, { message: 'Code must be exactly 6 digits' })
|
||||
.regex(/^\d{6}$/, { message: 'Code must contain only digits' }),
|
||||
});
|
||||
|
||||
type TExternal2FAFormSchema = z.infer<typeof ZExternal2FAFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthExternal2FA = ({
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthExternal2FAProps) => {
|
||||
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const statusQuery = trpc.envelope.signing2fa.getStatus.useQuery(
|
||||
{ token: recipient.token },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const verifyMutation = trpc.envelope.signing2fa.verify.useMutation();
|
||||
|
||||
const form = useForm<TExternal2FAFormSchema>({
|
||||
resolver: zodResolver(ZExternal2FAFormSchema),
|
||||
defaultValues: {
|
||||
code: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ code }: TExternal2FAFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
setFormError(null);
|
||||
|
||||
await verifyMutation.mutateAsync({
|
||||
token: recipient.token,
|
||||
code,
|
||||
});
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED) {
|
||||
setFormError('Too many failed attempts. Please request a new code.');
|
||||
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED) {
|
||||
setFormError('The code has expired. Please request a new code.');
|
||||
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED) {
|
||||
setFormError('No code has been issued yet. Please contact the document sender.');
|
||||
} else {
|
||||
setFormError('Invalid code. Please try again.');
|
||||
}
|
||||
|
||||
await statusQuery.refetch();
|
||||
form.reset({ code: '' });
|
||||
} finally {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ code: '' });
|
||||
setFormError(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const attemptsRemaining = statusQuery.data?.attemptsRemaining ?? null;
|
||||
const hasActiveToken = statusQuery.data?.hasActiveToken ?? false;
|
||||
const hasValidProof = statusQuery.data?.hasValidProof ?? false;
|
||||
|
||||
if (hasValidProof) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<Trans>Your identity has already been verified. You can proceed to sign.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasActiveToken && !statusQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>Verification code required</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
A verification code is required to sign this document. Please contact the document
|
||||
sender to request your code.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Enter the 6-digit verification code that was provided to you.</Trans>
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Verification code</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{attemptsRemaining !== null && attemptsRemaining > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>{attemptsRemaining} attempts remaining</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Verification failed</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{formError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>Verify</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
+9
-6
@@ -66,13 +66,13 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
export type DocumentSigningAuthProviderProps = {
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
isDirectTemplate?: boolean;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
@@ -181,11 +181,14 @@ export const DocumentSigningAuthProvider = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [passkeyData.passkeys]);
|
||||
|
||||
// Assume that a user must be logged in for any auth requirements.
|
||||
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
|
||||
(method) =>
|
||||
method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
);
|
||||
|
||||
const isAuthRedirectRequired = Boolean(
|
||||
derivedRecipientActionAuth &&
|
||||
derivedRecipientActionAuth.length > 0 &&
|
||||
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
|
||||
authMethodsRequiringLogin &&
|
||||
authMethodsRequiringLogin.length > 0 &&
|
||||
user?.email !== recipient.email,
|
||||
);
|
||||
|
||||
|
||||
@@ -114,8 +114,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
}))
|
||||
.with(undefined, () => undefined)
|
||||
.with(
|
||||
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
|
||||
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
|
||||
P.union(
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
),
|
||||
() => 'NOT_SUPPORTED' as const,
|
||||
)
|
||||
.exhaustive();
|
||||
@@ -165,7 +169,7 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
<DialogTitle>Automatically sign fields</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<div className="max-w-[50ch] text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
When you sign a document, we can automatically fill in and sign the following fields
|
||||
|
||||
@@ -161,6 +161,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
let authLevel = match(actionAuthMethod)
|
||||
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
||||
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
|
||||
.with('EXTERNAL_TWO_FACTOR_AUTH', () => _(msg`External Two-Factor Re-Authentication`))
|
||||
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
|
||||
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
||||
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
||||
|
||||
Reference in New Issue
Block a user