forked from documenso/documenso
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #140 from documenso/main
feat: add passkey support
- Loading branch information
Showing
35 changed files
with
973 additions
and
255 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
172 changes: 172 additions & 0 deletions
172
apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
import { useEffect, useState } from 'react'; | ||
|
||
import { zodResolver } from '@hookform/resolvers/zod'; | ||
import { useForm } from 'react-hook-form'; | ||
import { z } from 'zod'; | ||
|
||
import { AppError } from '@documenso/lib/errors/app-error'; | ||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; | ||
import { RecipientRole } from '@documenso/prisma/client'; | ||
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 { Input } from '@documenso/ui/primitives/input'; | ||
|
||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; | ||
|
||
import { useRequiredDocumentAuthContext } from './document-auth-provider'; | ||
|
||
export type DocumentActionAuth2FAProps = { | ||
actionTarget?: 'FIELD' | 'DOCUMENT'; | ||
actionVerb?: string; | ||
open: boolean; | ||
onOpenChange: (value: boolean) => void; | ||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void; | ||
}; | ||
|
||
const Z2FAAuthFormSchema = z.object({ | ||
token: z | ||
.string() | ||
.min(4, { message: 'Token must at least 4 characters long' }) | ||
.max(10, { message: 'Token must be at most 10 characters long' }), | ||
}); | ||
|
||
type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>; | ||
|
||
export const DocumentActionAuth2FA = ({ | ||
actionTarget = 'FIELD', | ||
actionVerb = 'sign', | ||
onReauthFormSubmit, | ||
open, | ||
onOpenChange, | ||
}: DocumentActionAuth2FAProps) => { | ||
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = | ||
useRequiredDocumentAuthContext(); | ||
|
||
const form = useForm<T2FAAuthFormSchema>({ | ||
resolver: zodResolver(Z2FAAuthFormSchema), | ||
defaultValues: { | ||
token: '', | ||
}, | ||
}); | ||
|
||
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); | ||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null); | ||
|
||
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { | ||
try { | ||
setIsCurrentlyAuthenticating(true); | ||
|
||
await onReauthFormSubmit({ | ||
type: DocumentAuth.TWO_FACTOR_AUTH, | ||
token, | ||
}); | ||
|
||
setIsCurrentlyAuthenticating(false); | ||
|
||
onOpenChange(false); | ||
} catch (err) { | ||
setIsCurrentlyAuthenticating(false); | ||
|
||
const error = AppError.parseError(err); | ||
setFormErrorCode(error.code); | ||
|
||
// Todo: Alert. | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
form.reset({ | ||
token: '', | ||
}); | ||
|
||
setIs2FASetupSuccessful(false); | ||
setFormErrorCode(null); | ||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, [open]); | ||
|
||
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) { | ||
return ( | ||
<div className="space-y-4"> | ||
<Alert variant="warning"> | ||
<AlertDescription> | ||
<p> | ||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' | ||
? 'You need to setup 2FA to mark this document as viewed.' | ||
: `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} | ||
</p> | ||
|
||
{user?.identityProvider === 'DOCUMENSO' && ( | ||
<p className="mt-2"> | ||
By enabling 2FA, you will be required to enter a code from your authenticator app | ||
every time you sign in. | ||
</p> | ||
)} | ||
</AlertDescription> | ||
</Alert> | ||
|
||
<DialogFooter> | ||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> | ||
Close | ||
</Button> | ||
|
||
<EnableAuthenticatorAppDialog onSuccess={() => setIs2FASetupSuccessful(true)} /> | ||
</DialogFooter> | ||
</div> | ||
); | ||
} | ||
|
||
return ( | ||
<Form {...form}> | ||
<form onSubmit={form.handleSubmit(onFormSubmit)}> | ||
<fieldset disabled={isCurrentlyAuthenticating}> | ||
<div className="space-y-4"> | ||
<FormField | ||
control={form.control} | ||
name="token" | ||
render={({ field }) => ( | ||
<FormItem> | ||
<FormLabel required>2FA token</FormLabel> | ||
|
||
<FormControl> | ||
<Input {...field} placeholder="Token" /> | ||
</FormControl> | ||
|
||
<FormMessage /> | ||
</FormItem> | ||
)} | ||
/> | ||
|
||
{formErrorCode && ( | ||
<Alert variant="destructive"> | ||
<AlertTitle>Unauthorized</AlertTitle> | ||
<AlertDescription> | ||
We were unable to verify your details. Please try again or contact support | ||
</AlertDescription> | ||
</Alert> | ||
)} | ||
|
||
<DialogFooter> | ||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> | ||
Cancel | ||
</Button> | ||
|
||
<Button type="submit" loading={isCurrentlyAuthenticating}> | ||
Sign | ||
</Button> | ||
</DialogFooter> | ||
</div> | ||
</fieldset> | ||
</form> | ||
</Form> | ||
); | ||
}; |
79 changes: 79 additions & 0 deletions
79
apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { useState } from 'react'; | ||
|
||
import { DateTime } from 'luxon'; | ||
import { signOut } from 'next-auth/react'; | ||
|
||
import { RecipientRole } from '@documenso/prisma/client'; | ||
import { trpc } from '@documenso/trpc/react'; | ||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; | ||
import { Button } from '@documenso/ui/primitives/button'; | ||
import { DialogFooter } from '@documenso/ui/primitives/dialog'; | ||
|
||
import { useRequiredDocumentAuthContext } from './document-auth-provider'; | ||
|
||
export type DocumentActionAuthAccountProps = { | ||
actionTarget?: 'FIELD' | 'DOCUMENT'; | ||
actionVerb?: string; | ||
onOpenChange: (value: boolean) => void; | ||
}; | ||
|
||
export const DocumentActionAuthAccount = ({ | ||
actionTarget = 'FIELD', | ||
actionVerb = 'sign', | ||
onOpenChange, | ||
}: DocumentActionAuthAccountProps) => { | ||
const { recipient } = useRequiredDocumentAuthContext(); | ||
|
||
const [isSigningOut, setIsSigningOut] = useState(false); | ||
|
||
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); | ||
|
||
const handleChangeAccount = async (email: string) => { | ||
try { | ||
setIsSigningOut(true); | ||
|
||
const encryptedEmail = await encryptSecondaryData({ | ||
data: email, | ||
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), | ||
}); | ||
|
||
await signOut({ | ||
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, | ||
}); | ||
} catch { | ||
setIsSigningOut(false); | ||
|
||
// Todo: Alert. | ||
} | ||
}; | ||
|
||
return ( | ||
<fieldset disabled={isSigningOut} className="space-y-4"> | ||
<Alert variant="warning"> | ||
<AlertDescription> | ||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( | ||
<span> | ||
To mark this document as viewed, you need to be logged in as{' '} | ||
<strong>{recipient.email}</strong> | ||
</span> | ||
) : ( | ||
<span> | ||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged | ||
in as <strong>{recipient.email}</strong> | ||
</span> | ||
)} | ||
</AlertDescription> | ||
</Alert> | ||
|
||
<DialogFooter> | ||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> | ||
Cancel | ||
</Button> | ||
|
||
<Button onClick={async () => handleChangeAccount(recipient.email)} loading={isSigningOut}> | ||
Login | ||
</Button> | ||
</DialogFooter> | ||
</fieldset> | ||
); | ||
}; |
Oops, something went wrong.