mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Merge branch 'main' into main
This commit is contained in:
@ -38,7 +38,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
||||
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
||||
'overflow-y-auto overflow-x-hidden':
|
||||
pathname && !['/singleplayer', '/pricing'].includes(pathname),
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
||||
@ -23,7 +23,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
|
||||
return (
|
||||
<div className={cn('', className)} {...props}>
|
||||
<div className="flex items-center justify-center gap-x-6">
|
||||
<div className="bg-background sticky top-32 flex items-center justify-end gap-x-6 shadow-[-1px_-5px_2px_6px_hsl(var(--background))] md:top-[7.5rem] lg:static lg:justify-center">
|
||||
<AnimatePresence>
|
||||
<motion.button
|
||||
key="MONTHLY"
|
||||
@ -40,7 +40,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
{period === 'MONTHLY' && (
|
||||
<motion.div
|
||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
@ -63,7 +63,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
{period === 'YEARLY' && (
|
||||
<motion.div
|
||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
@ -22,7 +22,10 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
@ -51,6 +54,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import {
|
||||
type DocumentData,
|
||||
type DocumentMeta,
|
||||
DocumentStatus,
|
||||
type Field,
|
||||
type Recipient,
|
||||
type User,
|
||||
} from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -34,12 +31,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
document: DocumentWithData;
|
||||
recipients: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
initialDocument: DocumentWithDetails;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
@ -48,12 +40,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'su
|
||||
|
||||
export const EditDocumentForm = ({
|
||||
className,
|
||||
document,
|
||||
recipients,
|
||||
fields,
|
||||
documentMeta,
|
||||
user: _user,
|
||||
documentData,
|
||||
initialDocument,
|
||||
documentRootPath,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
@ -62,10 +49,74 @@ export const EditDocumentForm = ({
|
||||
const searchParams = useSearchParams();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: document, refetch: refetchDocument } =
|
||||
trpc.document.getDocumentWithDetailsById.useQuery(
|
||||
{
|
||||
id: initialDocument.id,
|
||||
teamId: team?.id,
|
||||
},
|
||||
{
|
||||
initialData: initialDocument,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
},
|
||||
);
|
||||
|
||||
const { Recipient: recipients, Field: fields } = document;
|
||||
|
||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
id: initialDocument.id,
|
||||
teamId: team?.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newFields) => {
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
id: initialDocument.id,
|
||||
teamId: team?.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialDocument), Field: newFields }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newRecipients) => {
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
id: initialDocument.id,
|
||||
teamId: team?.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.document.getDocumentWithDetailsById.setData(
|
||||
{
|
||||
id: initialDocument.id,
|
||||
teamId: team?.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: setPasswordForDocument } =
|
||||
trpc.document.setPasswordForDocument.useMutation();
|
||||
|
||||
@ -112,13 +163,13 @@ export const EditDocumentForm = ({
|
||||
|
||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addTitle({
|
||||
documentId: document.id,
|
||||
teamId: team?.id,
|
||||
title: data.title,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('signers');
|
||||
@ -135,14 +186,15 @@ export const EditDocumentForm = ({
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addSigners({
|
||||
documentId: document.id,
|
||||
teamId: team?.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -157,13 +209,14 @@ export const EditDocumentForm = ({
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('subject');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -219,6 +272,15 @@ export const EditDocumentForm = ({
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
/**
|
||||
* Refresh the data in the background when steps change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
void refetchDocument();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
@ -227,10 +289,10 @@ export const EditDocumentForm = ({
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
key={document.documentData.id}
|
||||
documentData={document.documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
password={document.documentMeta?.password}
|
||||
onPasswordSubmit={onPasswordSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@ -5,9 +5,7 @@ import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
@ -37,13 +35,13 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
const document = await getDocumentWithDetailsById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
if (!document) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
@ -51,7 +49,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
redirect(`${documentRootPath}/${documentId}`);
|
||||
}
|
||||
|
||||
const { documentData, documentMeta } = document;
|
||||
const { documentMeta, Recipient: recipients } = document;
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
@ -70,18 +68,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
@ -109,12 +95,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
initialDocument={document}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -15,15 +15,14 @@ export default function SettingsSecurityActivityPage() {
|
||||
<SettingsHeader
|
||||
title="Security activity"
|
||||
subtitle="View all recent security activity related to your account."
|
||||
hideDivider={true}
|
||||
>
|
||||
<div>
|
||||
<ActivityPageBackButton />
|
||||
</div>
|
||||
</SettingsHeader>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<div className="mt-4">
|
||||
<UserSecurityActivityDataTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -18,6 +19,8 @@ export const metadata: Metadata = {
|
||||
export default async function SecuritySettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
@ -25,11 +28,13 @@ export default async function SecuritySettingsPage() {
|
||||
subtitle="Here you can manage your password and security settings."
|
||||
/>
|
||||
|
||||
{user.identityProvider === 'DOCUMENSO' ? (
|
||||
<div>
|
||||
{user.identityProvider === 'DOCUMENSO' && (
|
||||
<>
|
||||
<PasswordForm user={user} />
|
||||
|
||||
<hr className="border-border/50 mt-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
@ -39,12 +44,18 @@ export default async function SecuritySettingsPage() {
|
||||
<AlertTitle>Two factor authentication</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Create one-time passwords that serve as a secondary authentication method for
|
||||
confirming your identity when requested during the sign-in process.
|
||||
Add an authenticator to serve as a secondary authentication method{' '}
|
||||
{user.identityProvider === 'DOCUMENSO'
|
||||
? 'when signing in, or when signing documents.'
|
||||
: 'for signing documents.'}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
{user.twoFactorEnabled ? (
|
||||
<DisableAuthenticatorAppDialog />
|
||||
) : (
|
||||
<EnableAuthenticatorAppDialog />
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
@ -56,26 +67,31 @@ export default async function SecuritySettingsPage() {
|
||||
<AlertTitle>Recovery codes</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Two factor authentication recovery codes are used to access your account in the
|
||||
event that you lose access to your authenticator app.
|
||||
Two factor authentication recovery codes are used to access your account in the event
|
||||
that you lose access to your authenticator app.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
<ViewRecoveryCodesDialog />
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>
|
||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
To update your password, enable two-factor authentication, and manage other security
|
||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||
settings.
|
||||
{isPasskeyEnabled && (
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Passkeys</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" className="bg-background">
|
||||
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@ -91,7 +107,7 @@ export default async function SecuritySettingsPage() {
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Button asChild variant="outline" className="bg-background">
|
||||
<Link href="/settings/security/activity">View activity</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
|
||||
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type CreatePasskeyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreatePasskeyFormSchema = z.object({
|
||||
passkeyName: z.string().min(3),
|
||||
});
|
||||
|
||||
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TCreatePasskeyFormSchema>({
|
||||
resolver: zodResolver(ZCreatePasskeyFormSchema),
|
||||
defaultValues: {
|
||||
passkeyName: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createPasskeyRegistrationOptions, isLoading } =
|
||||
trpc.auth.createPasskeyRegistrationOptions.useMutation();
|
||||
|
||||
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
|
||||
|
||||
const registrationResult = await startRegistration(passkeyRegistrationOptions);
|
||||
|
||||
await createPasskey({
|
||||
passkeyName,
|
||||
verificationResponse: registrationResult,
|
||||
});
|
||||
|
||||
toast({
|
||||
description: 'Successfully created passkey',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setFormError(err.code || error.code);
|
||||
}
|
||||
};
|
||||
|
||||
const extractDefaultPasskeyName = () => {
|
||||
if (!window || !window.navigator) {
|
||||
return;
|
||||
}
|
||||
|
||||
parser.setUA(window.navigator.userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
const operatingSystem = result.os.name;
|
||||
const browser = result.browser.name;
|
||||
|
||||
let passkeyName = '';
|
||||
|
||||
if (operatingSystem && browser) {
|
||||
passkeyName = `${browser} (${operatingSystem})`;
|
||||
}
|
||||
|
||||
return passkeyName;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||
|
||||
form.reset({
|
||||
passkeyName: defaultPasskeyName,
|
||||
});
|
||||
|
||||
setFormError(null);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary" loading={isLoading}>
|
||||
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||
Add passkey
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add passkey</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="passkeyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Passkey name</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
When you click continue, you will be prompted to add the first available
|
||||
authenticator on your system.
|
||||
</AlertDescription>
|
||||
|
||||
<AlertDescription className="mt-2">
|
||||
If you do not want to use the authenticator prompted, you can close it, which will
|
||||
then display the next available authenticator.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{formError && (
|
||||
<Alert variant="destructive">
|
||||
{match(formError)
|
||||
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
||||
<AlertDescription>This passkey has already been registered.</AlertDescription>
|
||||
))
|
||||
.with('TOO_MANY_PASSKEYS', () => (
|
||||
<AlertDescription>
|
||||
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
|
||||
</AlertDescription>
|
||||
))
|
||||
.with('InvalidStateError', () => (
|
||||
<>
|
||||
<AlertTitle className="text-sm">
|
||||
Passkey creation cancelled due to one of the following reasons:
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
<li>Cancelled by user</li>
|
||||
<li>Passkey already exists for the provided authenticator</li>
|
||||
<li>Exceeded timeout</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<AlertDescription>
|
||||
Something went wrong. Please try again or contact support.
|
||||
</AlertDescription>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { CreatePasskeyDialog } from './create-passkey-dialog';
|
||||
import { UserPasskeysDataTable } from './user-passkeys-data-table';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Manage passkeys',
|
||||
};
|
||||
|
||||
export default async function SettingsManagePasskeysPage() {
|
||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||
|
||||
if (!isPasskeyEnabled) {
|
||||
redirect('/settings/security');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
|
||||
<CreatePasskeyDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<UserPasskeysDataTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UserPasskeysDataTableActionsProps = {
|
||||
className?: string;
|
||||
passkeyId: string;
|
||||
passkeyName: string;
|
||||
};
|
||||
|
||||
const ZUpdatePasskeySchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||
|
||||
export const UserPasskeysDataTableActions = ({
|
||||
className,
|
||||
passkeyId,
|
||||
passkeyName,
|
||||
}: UserPasskeysDataTableActionsProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||
|
||||
const form = useForm<TUpdatePasskeySchema>({
|
||||
resolver: zodResolver(ZUpdatePasskeySchema),
|
||||
defaultValues: {
|
||||
name: passkeyName,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePasskey, isLoading: isUpdatingPasskey } =
|
||||
trpc.auth.updatePasskey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Passkey has been updated',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description:
|
||||
'We are unable to update this passkey at the moment. Please try again later.',
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deletePasskey, isLoading: isDeletingPasskey } =
|
||||
trpc.auth.deletePasskey.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Passkey has been removed',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description:
|
||||
'We are unable to remove this passkey at the moment. Please try again later.',
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex justify-end space-x-2', className)}>
|
||||
<Dialog
|
||||
open={isUpdateDialogOpen}
|
||||
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
<Button variant="outline">Edit</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update passkey</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async ({ name }) =>
|
||||
updatePasskey({
|
||||
passkeyId,
|
||||
name,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<fieldset className="flex h-full flex-col" disabled={isUpdatingPasskey}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel required>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={isUpdatingPasskey}>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete passkey</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset disabled={isDeletingPasskey}>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
onClick={async () =>
|
||||
deletePasskey({
|
||||
passkeyId,
|
||||
})
|
||||
}
|
||||
variant="destructive"
|
||||
loading={isDeletingPasskey}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
|
||||
|
||||
export const UserPasskeysDataTable = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
|
||||
{
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
|
||||
},
|
||||
|
||||
{
|
||||
header: 'Last used',
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) =>
|
||||
row.original.lastUsedAt
|
||||
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
|
||||
: 'Never',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<UserPasskeysDataTableActions
|
||||
className="justify-end"
|
||||
passkeyId={row.original.id}
|
||||
passkeyName={row.original.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||
onClearFilters={() => router.push(pathname ?? '/')}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading && isInitialLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Skeleton className="h-8 w-16 rounded" />
|
||||
<Skeleton className="h-8 w-16 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@ -11,6 +11,7 @@ import {
|
||||
convertToLocalSystemFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -38,12 +39,12 @@ export const DateField = ({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -29,12 +30,12 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -34,12 +35,12 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -35,12 +36,12 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const { Signature: signature } = field;
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -30,12 +31,12 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
|
||||
@ -14,6 +14,10 @@ import {
|
||||
SETTINGS_PAGE_SHORTCUT,
|
||||
TEMPLATES_PAGE_SHORTCUT,
|
||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||
import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
@ -82,6 +86,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
// Do not batch this due to relatively long request time compared to
|
||||
// other queries which are generally batched with this.
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
export type SettingsHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
hideDivider?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
|
||||
export const SettingsHeader = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
hideDivider,
|
||||
}: SettingsHeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||
@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
{!hideDivider && <hr className="my-4" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
|
||||
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
|
||||
|
||||
type AuthenticatorAppProps = {
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
|
||||
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
|
||||
|
||||
const isEnableDialogOpen = modalState === 'enable';
|
||||
const isDisableDialogOpen = modalState === 'disable';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-shrink-0">
|
||||
{isTwoFactorEnabled ? (
|
||||
<Button variant="destructive" onClick={() => setModalState('disable')}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EnableAuthenticatorAppDialog
|
||||
open={isEnableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
|
||||
<DisableAuthenticatorAppDialog
|
||||
open={isDisableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@ -9,65 +13,51 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string(),
|
||||
export const ZDisable2FAForm = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
||||
typeof ZDisableTwoFactorAuthenticationForm
|
||||
>;
|
||||
export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
|
||||
|
||||
export type DisableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DisableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DisableAuthenticatorAppDialogProps) => {
|
||||
export const DisableAuthenticatorAppDialog = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
||||
trpc.twoFactorAuthentication.disable.useMutation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
||||
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
|
||||
|
||||
const disable2FAForm = useForm<TDisable2FAForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
backupCode: '',
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
||||
resolver: zodResolver(ZDisable2FAForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
||||
disableTwoFactorAuthenticationForm.formState;
|
||||
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
|
||||
|
||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
backupCode,
|
||||
}: TDisableTwoFactorAuthenticationForm) => {
|
||||
const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
|
||||
try {
|
||||
await disableTwoFactorAuthentication({ password, backupCode });
|
||||
await disable2FA({ token });
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication disabled',
|
||||
@ -76,7 +66,7 @@ export const DisableAuthenticatorAppDialog = ({
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
@ -91,74 +81,51 @@ export const DisableAuthenticatorAppDialog = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild={true}>
|
||||
<Button className="flex-shrink-0" variant="destructive">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Authenticator App</DialogTitle>
|
||||
<DialogTitle>Disable 2FA</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
To disable the Authenticator App for your account, please enter your password and a
|
||||
backup code. If you do not have a backup code available, please contact support.
|
||||
Please provide a token from the authenticator, or a backup code. If you do not have a
|
||||
backup code available, please contact support.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...disableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onDisableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<fieldset
|
||||
className="flex flex-col gap-y-4"
|
||||
disabled={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
<Form {...disable2FAForm}>
|
||||
<form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
|
||||
<fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
|
||||
<FormField
|
||||
name="password"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
name="token"
|
||||
control={disable2FAForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
<Input {...field} placeholder="Token" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="backupCode"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
<Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -11,11 +14,13 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
@ -26,85 +31,60 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
||||
|
||||
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
||||
export const ZEnable2FAForm = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||
export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
|
||||
|
||||
export type EnableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EnableAuthenticatorAppDialogProps) => {
|
||||
export const EnableAuthenticatorAppDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.setup.useMutation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
|
||||
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: enableTwoFactorAuthentication,
|
||||
data: enableTwoFactorAuthenticationData,
|
||||
isLoading: isEnableTwoFactorAuthenticationDataLoading,
|
||||
} = trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
mutateAsync: setup2FA,
|
||||
data: setup2FAData,
|
||||
isLoading: isSettingUp2FA,
|
||||
} = trpc.twoFactorAuthentication.setup.useMutation({
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
description:
|
||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||
setupTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||
const enable2FAForm = useForm<TEnable2FAForm>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
||||
resolver: zodResolver(ZEnable2FAForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
||||
enableTwoFactorAuthenticationForm.formState;
|
||||
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
||||
return 'setup';
|
||||
}
|
||||
|
||||
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
||||
return 'enable';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [
|
||||
setupTwoFactorAuthenticationData,
|
||||
isSetupTwoFactorAuthenticationSubmitting,
|
||||
enableTwoFactorAuthenticationData,
|
||||
isEnableTwoFactorAuthenticationSubmitting,
|
||||
]);
|
||||
|
||||
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
}: TSetupTwoFactorAuthenticationForm) => {
|
||||
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
||||
try {
|
||||
await setupTwoFactorAuthentication({ password });
|
||||
const data = await enable2FA({ code: token });
|
||||
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication enabled',
|
||||
description:
|
||||
'You will now be required to enter a code from your authenticator app when signing in.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
@ -116,8 +96,8 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
};
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
|
||||
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
|
||||
if (recoveryCodes) {
|
||||
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
@ -128,111 +108,80 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||
token,
|
||||
}: TEnableTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
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.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
description:
|
||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
const handleEnable2FA = async () => {
|
||||
if (!setup2FAData) {
|
||||
await setup2FA();
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the form when the Dialog closes
|
||||
if (!open) {
|
||||
setupTwoFactorAuthenticationForm.reset();
|
||||
enable2FAForm.reset();
|
||||
|
||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||
setRecoveryCodes(null);
|
||||
router.refresh();
|
||||
}
|
||||
}, [open, setupTwoFactorAuthenticationForm]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild={true}>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
loading={isSettingUp2FA}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleEnable2FA();
|
||||
}}
|
||||
>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
{setup2FAData && (
|
||||
<>
|
||||
{recoveryCodes ? (
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||
|
||||
{step === 'setup' && (
|
||||
<DialogDescription>
|
||||
To enable two-factor authentication, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogTitle>Backup codes</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with('setup', () => {
|
||||
return (
|
||||
<Form {...setupTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
||||
onSetupTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={setupTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Close</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('enable', () => (
|
||||
<Form {...enableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onEnableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
</div>
|
||||
) : (
|
||||
<Form {...enable2FAForm}>
|
||||
<form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||
<DialogDescription>
|
||||
To enable two-factor authentication, scan the following QR code using your
|
||||
authenticator app.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
|
||||
<div
|
||||
className="flex h-36 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
||||
__html: renderSVG(setup2FAData?.uri ?? ''),
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -242,7 +191,7 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
</p>
|
||||
|
||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||
{setupTwoFactorAuthenticationData?.secret}
|
||||
{setup2FAData?.secret}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@ -252,7 +201,7 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
control={enableTwoFactorAuthenticationForm.control}
|
||||
control={enable2FAForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
@ -265,38 +214,20 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||
<Button type="submit" loading={isEnabling2FA}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
))
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={downloadRecoveryCodes}
|
||||
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
|
||||
loading={isEnableTwoFactorAuthenticationDataLoading}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||
|
||||
type RecoveryCodesProps = {
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onClick={() => setIsOpen(true)}
|
||||
disabled={!isTwoFactorEnabled}
|
||||
>
|
||||
View Codes
|
||||
</Button>
|
||||
|
||||
<ViewRecoveryCodesDialog
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -6,69 +8,61 @@ import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZViewRecoveryCodesForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
token: z.string().min(1, { message: 'Token is required' }),
|
||||
});
|
||||
|
||||
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
||||
|
||||
export type ViewRecoveryCodesDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
export const ViewRecoveryCodesDialog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: viewRecoveryCodes,
|
||||
data: viewRecoveryCodesData,
|
||||
isLoading: isViewRecoveryCodesDataLoading,
|
||||
data: recoveryCodes,
|
||||
mutate,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
// error?.data?.code
|
||||
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
||||
return 'authenticate';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
|
||||
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
|
||||
if (recoveryCodes) {
|
||||
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
@ -79,105 +73,88 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
||||
}
|
||||
};
|
||||
|
||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||
try {
|
||||
await viewRecoveryCodes({ password });
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to view recovery codes',
|
||||
description:
|
||||
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the form when the Dialog closes
|
||||
if (!open) {
|
||||
viewRecoveryCodesForm.reset();
|
||||
}
|
||||
}, [open, viewRecoveryCodesForm]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex-shrink-0">View Codes</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
{recoveryCodes ? (
|
||||
<div>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||
|
||||
{step === 'authenticate' && (
|
||||
<DialogDescription>
|
||||
To view your recovery codes, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with('authenticate', () => {
|
||||
return (
|
||||
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Close</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...viewRecoveryCodesForm}>
|
||||
<form
|
||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please provide a token from your authenticator, or a backup code.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset className="flex flex-col space-y-4" disabled={isLoading}>
|
||||
<FormField
|
||||
name="password"
|
||||
name="token"
|
||||
control={viewRecoveryCodesForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
<Input {...field} placeholder="Token" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{viewRecoveryCodesData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{match(AppError.parseError(error).message)
|
||||
.with(
|
||||
ErrorCode.INCORRECT_TWO_FACTOR_CODE,
|
||||
() => 'Invalid code. Please try again.',
|
||||
)
|
||||
.otherwise(
|
||||
() => 'Something went wrong. Please try again or contact support.',
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!viewRecoveryCodesData?.recoveryCodes}
|
||||
loading={isViewRecoveryCodesDataLoading}
|
||||
onClick={downloadRecoveryCodes}
|
||||
>
|
||||
Download
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={isLoading}>
|
||||
View
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -6,12 +6,18 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -66,14 +72,24 @@ export type SignInFormProps = {
|
||||
|
||||
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const isPasskeyEnabled = getFlag('app_passkey');
|
||||
|
||||
const { mutateAsync: createPasskeySigninOptions } =
|
||||
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||
|
||||
const form = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: initialEmail ?? '',
|
||||
@ -107,6 +123,63 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
setTwoFactorAuthenticationMethod(method);
|
||||
};
|
||||
|
||||
const onSignInWithPasskey = async () => {
|
||||
if (!browserSupportsWebAuthn) {
|
||||
toast({
|
||||
title: 'Not supported',
|
||||
description: 'Passkeys are not supported on this browser',
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPasskeyLoading(true);
|
||||
|
||||
const options = await createPasskeySigninOptions();
|
||||
|
||||
const credential = await startAuthentication(options);
|
||||
|
||||
const result = await signIn('webauthn', {
|
||||
credential: JSON.stringify(credential),
|
||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (!result?.url || result.error) {
|
||||
throw new AppError(result?.error ?? '');
|
||||
}
|
||||
|
||||
window.location.href = result.url;
|
||||
} catch (err) {
|
||||
setIsPasskeyLoading(false);
|
||||
|
||||
if (err.name === 'NotAllowedError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AppErrorCode.NOT_SETUP,
|
||||
() =>
|
||||
'This passkey is not configured for this application. Please login and add one in the user settings.',
|
||||
)
|
||||
.with(AppErrorCode.EXPIRED_CODE, () => 'This session has expired. Please try again.')
|
||||
.otherwise(() => 'Please try again later or login using your normal details');
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: errorMessage,
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||
try {
|
||||
const credentials: Record<string, string> = {
|
||||
@ -189,7 +262,10 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<fieldset
|
||||
className="flex w-full flex-col gap-y-4"
|
||||
disabled={isSubmitting || isPasskeyLoading}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
@ -217,6 +293,8 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<p className="mt-2 text-right">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
@ -225,11 +303,9 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
@ -240,14 +316,15 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<>
|
||||
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
@ -259,8 +336,23 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPasskeyEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
loading={isPasskeyLoading}
|
||||
className="bg-background text-muted-foreground border"
|
||||
onClick={onSignInWithPasskey}
|
||||
>
|
||||
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
||||
Passkey
|
||||
</Button>
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<Dialog
|
||||
|
||||
169
package-lock.json
generated
169
package-lock.json
generated
@ -100,7 +100,10 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
@ -129,6 +132,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
@ -2628,6 +2632,11 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
|
||||
@ -3252,6 +3261,11 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.2.tgz",
|
||||
"integrity": "sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw=="
|
||||
},
|
||||
"node_modules/@manypkg/find-root": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz",
|
||||
@ -4611,6 +4625,60 @@
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.10.tgz",
|
||||
"integrity": "sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz",
|
||||
"integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz",
|
||||
"integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz",
|
||||
"integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.5",
|
||||
"pvtsutils": "^1.3.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz",
|
||||
"integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"asn1js": "^3.0.5",
|
||||
"ipaddr.js": "^2.1.0",
|
||||
"pvtsutils": "^1.3.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@ -6441,6 +6509,38 @@
|
||||
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
|
||||
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz",
|
||||
"integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz",
|
||||
"integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.3.10",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"cross-fetch": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz",
|
||||
"integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w=="
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
@ -8616,6 +8716,19 @@
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
|
||||
"integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.2",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
|
||||
@ -9968,6 +10081,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-es": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.0.0.tgz",
|
||||
"integrity": "sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ=="
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
|
||||
@ -10052,6 +10170,33 @@
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@ -13579,6 +13724,14 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
|
||||
"integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
@ -17919,6 +18072,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz",
|
||||
"integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
|
||||
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
|
||||
|
||||
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
@ -73,3 +76,264 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to create a document with multiple recipients', async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Sign in
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Title').fill(documentTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email*').fill('user1@example.com');
|
||||
await page.getByLabel('Name').fill('User 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email*').nth(1).fill('user2@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('User 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'User 1 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByText('User 1 (user1@example.com)').click();
|
||||
await page.getByText('User 2 (user2@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'User 2 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to create, send and sign a document', async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Sign in
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Title').fill(documentTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email*').fill('user1@example.com');
|
||||
await page.getByLabel('Name').fill('User 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
await page.getByRole('link', { name: documentTitle }).click();
|
||||
|
||||
const url = await page.url().split('/');
|
||||
const documentId = url[url.length - 1];
|
||||
|
||||
const { token } = await getRecipientByEmail({
|
||||
email: 'user1@example.com',
|
||||
documentId: Number(documentId),
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
// Check if document has been viewed
|
||||
const { status } = await getDocumentByToken({ token });
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('You have signed')).toBeVisible();
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken({ token });
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Sign in
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Title').fill(documentTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email*').fill('user1@example.com');
|
||||
await page.getByLabel('Name').fill('User 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
await page.getByRole('link', { name: documentTitle }).click();
|
||||
|
||||
const url = await page.url().split('/');
|
||||
const documentId = url[url.length - 1];
|
||||
|
||||
const { token } = await getRecipientByEmail({
|
||||
email: 'user1@example.com',
|
||||
documentId: Number(documentId),
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
// Check if document has been viewed
|
||||
const { status } = await getDocumentByToken({ token });
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL('https://documenso.com');
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken({ token });
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
37
packages/app-tests/e2e/test-update-user-name.spec.ts
Normal file
37
packages/app-tests/e2e/test-update-user-name.spec.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from './fixtures/authentication';
|
||||
|
||||
test('update user name', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
await page.getByLabel('Full Name').fill('John Doe');
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Update profile' }).click();
|
||||
|
||||
// wait for it to finish
|
||||
await expect(page.getByText('Profile updated', { exact: true })).toBeVisible();
|
||||
|
||||
await page.waitForURL('/settings/profile');
|
||||
|
||||
expect((await getUserByEmail({ email: user.email })).name).toEqual('John Doe');
|
||||
});
|
||||
@ -6,6 +6,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test:dev": "playwright test",
|
||||
"test-ui:dev": "playwright test --ui",
|
||||
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@ -16,10 +16,24 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s
|
||||
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
||||
[UserSecurityAuditLogType.PASSKEY_CREATED]: 'Passkey created',
|
||||
[UserSecurityAuditLogType.PASSKEY_DELETED]: 'Passkey deleted',
|
||||
[UserSecurityAuditLogType.PASSKEY_UPDATED]: 'Passkey updated',
|
||||
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
||||
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
||||
[UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL]: 'Passkey sign in failed',
|
||||
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
||||
};
|
||||
|
||||
/**
|
||||
* The duration to wait for a passkey to be verified in MS.
|
||||
*/
|
||||
export const PASSKEY_TIMEOUT = 60000;
|
||||
|
||||
/**
|
||||
* The maximum number of passkeys are user can have.
|
||||
*/
|
||||
export const MAXIMUM_PASSKEYS = 50;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { APP_BASE_URL } from './app';
|
||||
import { APP_BASE_URL, WEBAPP_BASE_URL } from './app';
|
||||
|
||||
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
||||
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
||||
@ -23,6 +23,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||
app_document_page_view_history_sheet: false,
|
||||
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
|
||||
marketing_header_single_player_mode: false,
|
||||
marketing_profiles_announcement_bar: true,
|
||||
} as const;
|
||||
|
||||
25
packages/lib/constants/trpc.ts
Normal file
25
packages/lib/constants/trpc.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* For TRPC useQueries that should not be batched with other queries.
|
||||
*/
|
||||
export const SKIP_QUERY_BATCH_META = {
|
||||
trpc: {
|
||||
context: {
|
||||
skipBatch: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* For TRPC useQueries and useMutations to adjust the logic on when query invalidation
|
||||
* should occur.
|
||||
*
|
||||
* When used in:
|
||||
* - useQuery: Will not invalidate the given query when a mutation occurs.
|
||||
* - useMutation: Will not trigger invalidation on all queries when mutation succeeds.
|
||||
*
|
||||
*/
|
||||
export const DO_NOT_INVALIDATE_QUERY_ON_MUTATION = {
|
||||
meta: {
|
||||
doNotInvalidateQueryOnMutation: true,
|
||||
},
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
/// <reference types="../types/next-auth.d.ts" />
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { AuthOptions, Session, User } from 'next-auth';
|
||||
import type { JWT } from 'next-auth/jwt';
|
||||
@ -12,12 +13,16 @@ import { env } from 'next-runtime-env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
||||
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||
import { getAuthenticatorRegistrationOptions } from '../utils/authenticator';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
@ -131,6 +136,113 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
};
|
||||
},
|
||||
}),
|
||||
CredentialsProvider({
|
||||
id: 'webauthn',
|
||||
name: 'Keypass',
|
||||
credentials: {
|
||||
csrfToken: { label: 'csrfToken', type: 'csrfToken' },
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
const csrfToken = credentials?.csrfToken;
|
||||
|
||||
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
|
||||
|
||||
try {
|
||||
const parsedBodyCredential = JSON.parse(req.body?.credential);
|
||||
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
|
||||
} catch {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
const challengeToken = await prisma.anonymousVerificationToken
|
||||
.delete({
|
||||
where: {
|
||||
id: csrfToken,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!challengeToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (challengeToken.expiresAt < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE);
|
||||
}
|
||||
|
||||
const passkey = await prisma.passkey.findFirst({
|
||||
where: {
|
||||
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
|
||||
},
|
||||
include: {
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||
}
|
||||
|
||||
const user = passkey.User;
|
||||
|
||||
const { rpId, origin } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: requestBodyCrediential,
|
||||
expectedChallenge: challengeToken.token,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
authenticator: {
|
||||
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
|
||||
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
counter: Number(passkey.counter),
|
||||
},
|
||||
}).catch(() => null);
|
||||
|
||||
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||
|
||||
if (!verification?.verified) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await prisma.passkey.update({
|
||||
where: {
|
||||
id: passkey.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
emailVerified: user.emailVerified?.toISOString() ?? null,
|
||||
} satisfies User;
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, account }) {
|
||||
|
||||
@ -1,40 +1,30 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
token: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
token,
|
||||
user,
|
||||
password,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
@ -17,25 +17,38 @@ export const enableTwoFactorAuthentication = async ({
|
||||
code,
|
||||
requestMetadata,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||
throw new AppError('TWO_FACTOR_ALREADY_ENABLED');
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
|
||||
}
|
||||
|
||||
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
let recoveryCodes: string[] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updatedUser = await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
recoveryCodes = getBackupCodes({ user: updatedUser }) ?? [];
|
||||
|
||||
if (recoveryCodes.length === 0) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@ -44,18 +57,7 @@ export const enableTwoFactorAuthentication = async ({
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
return { recoveryCodes };
|
||||
};
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { base32 } from '@scure/base';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
@ -12,14 +11,12 @@ import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
type SetupTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const ISSUER = 'Documenso';
|
||||
|
||||
export const setupTwoFactorAuthentication = async ({
|
||||
user,
|
||||
password,
|
||||
}: SetupTwoFactorAuthenticationOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
@ -27,20 +24,6 @@ export const setupTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = Array.from({ length: 10 })
|
||||
|
||||
30
packages/lib/server-only/2fa/view-backup-codes.ts
Normal file
30
packages/lib/server-only/2fa/view-backup-codes.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type ViewBackupCodesOptions = {
|
||||
user: User;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => {
|
||||
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
|
||||
if (!isValid) {
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
const backupCodes = getBackupCodes({ user });
|
||||
|
||||
if (!backupCodes) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
return backupCodes;
|
||||
};
|
||||
@ -0,0 +1,58 @@
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server';
|
||||
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { PASSKEY_TIMEOUT } from '../../constants/auth';
|
||||
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeyRegistrationOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const createPasskeyRegistrationOptions = async ({
|
||||
userId,
|
||||
}: CreatePasskeyRegistrationOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
passkeys: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { passkeys } = user;
|
||||
|
||||
const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID: userId.toString(),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name ?? undefined,
|
||||
timeout: PASSKEY_TIMEOUT,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: passkeys.map((passkey) => ({
|
||||
id: passkey.credentialId,
|
||||
type: 'public-key',
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
transports: passkey.transports as AuthenticatorTransportFuture[],
|
||||
})),
|
||||
});
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
userId,
|
||||
token: options.challenge,
|
||||
expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
identifier: 'PASSKEY_CHALLENGE',
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeySigninOptions = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
|
||||
const { rpId, timeout } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
userVerification: 'preferred',
|
||||
timeout,
|
||||
});
|
||||
|
||||
const { challenge } = options;
|
||||
|
||||
await prisma.anonymousVerificationToken.upsert({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
update: {
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: sessionId,
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
106
packages/lib/server-only/auth/create-passkey.ts
Normal file
106
packages/lib/server-only/auth/create-passkey.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server';
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { MAXIMUM_PASSKEYS } from '../../constants/auth';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeyOptions = {
|
||||
userId: number;
|
||||
passkeyName: string;
|
||||
verificationResponse: RegistrationResponseJSON;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const createPasskey = async ({
|
||||
userId,
|
||||
passkeyName,
|
||||
verificationResponse,
|
||||
requestMetadata,
|
||||
}: CreatePasskeyOptions) => {
|
||||
const { _count } = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
passkeys: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (_count.passkeys >= MAXIMUM_PASSKEYS) {
|
||||
throw new AppError('TOO_MANY_PASSKEYS');
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
identifier: 'PASSKEY_CHALLENGE',
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Challenge token not found');
|
||||
}
|
||||
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
identifier: 'PASSKEY_CHALLENGE',
|
||||
},
|
||||
});
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
|
||||
}
|
||||
|
||||
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: verificationResponse,
|
||||
expectedChallenge: verificationToken.token,
|
||||
expectedOrigin,
|
||||
expectedRPID,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Verification failed');
|
||||
}
|
||||
|
||||
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =
|
||||
verification.registrationInfo;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.passkey.create({
|
||||
data: {
|
||||
userId,
|
||||
name: passkeyName,
|
||||
credentialId: Buffer.from(credentialID),
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
counter,
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
transports: verificationResponse.response.transports,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: UserSecurityAuditLogType.PASSKEY_CREATED,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
41
packages/lib/server-only/auth/delete-passkey.ts
Normal file
41
packages/lib/server-only/auth/delete-passkey.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
|
||||
export interface DeletePasskeyOptions {
|
||||
userId: number;
|
||||
passkeyId: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
export const deletePasskey = async ({
|
||||
userId,
|
||||
passkeyId,
|
||||
requestMetadata,
|
||||
}: DeletePasskeyOptions) => {
|
||||
await prisma.passkey.findFirstOrThrow({
|
||||
where: {
|
||||
id: passkeyId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.passkey.delete({
|
||||
where: {
|
||||
id: passkeyId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: UserSecurityAuditLogType.PASSKEY_DELETED,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
71
packages/lib/server-only/auth/find-passkeys.ts
Normal file
71
packages/lib/server-only/auth/find-passkeys.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Passkey } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export interface FindPasskeysOptions {
|
||||
userId: number;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Passkey;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findPasskeys = async ({
|
||||
userId,
|
||||
term = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindPasskeysOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'name';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const whereClause: Prisma.PasskeyWhereInput = {
|
||||
userId,
|
||||
};
|
||||
|
||||
if (term.length > 0) {
|
||||
whereClause.name = {
|
||||
contains: term,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.passkey.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastUsedAt: true,
|
||||
counter: true,
|
||||
credentialDeviceType: true,
|
||||
credentialBackedUp: true,
|
||||
transports: true,
|
||||
},
|
||||
}),
|
||||
prisma.passkey.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
51
packages/lib/server-only/auth/update-passkey.ts
Normal file
51
packages/lib/server-only/auth/update-passkey.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
|
||||
export interface UpdateAuthenticatorsOptions {
|
||||
userId: number;
|
||||
passkeyId: string;
|
||||
name: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
export const updatePasskey = async ({
|
||||
userId,
|
||||
passkeyId,
|
||||
name,
|
||||
requestMetadata,
|
||||
}: UpdateAuthenticatorsOptions) => {
|
||||
const passkey = await prisma.passkey.findFirstOrThrow({
|
||||
where: {
|
||||
id: passkeyId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (passkey.name === name) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.passkey.update({
|
||||
where: {
|
||||
id: passkeyId,
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: UserSecurityAuditLogType.PASSKEY_UPDATED,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -1,13 +1,30 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
export type GetDocumentByTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type GetDocumentAndSenderByTokenOptions = GetDocumentByTokenOptions;
|
||||
export type GetDocumentAndRecipientByTokenOptions = GetDocumentByTokenOptions;
|
||||
|
||||
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
export interface GetDocumentAndRecipientByTokenOptions {
|
||||
token: string;
|
||||
}
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getDocumentAndSenderByToken = async ({
|
||||
token,
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type GetDocumentWithDetailsByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const getDocumentWithDetailsById = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId: id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
Field: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -5,7 +5,7 @@ import {
|
||||
diffFieldChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType } from '@documenso/prisma/client';
|
||||
import type { Field, FieldType } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
@ -29,7 +29,7 @@ export const setFieldsForDocument = async ({
|
||||
documentId,
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: SetFieldsForDocumentOptions) => {
|
||||
}: SetFieldsForDocumentOptions): Promise<Field[]> => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
@ -99,7 +99,7 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
return await Promise.all(
|
||||
linkedFields.map(async (field) => {
|
||||
const fieldSignerEmail = field.signerEmail.toLowerCase();
|
||||
|
||||
@ -218,5 +218,13 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return persistedFields;
|
||||
// Filter out fields that have been removed or have been updated.
|
||||
const filteredFields = existingFields.filter((field) => {
|
||||
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
return [...filteredFields, ...persistedFields];
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
diffRecipientChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
@ -28,7 +29,7 @@ export const setRecipientsForDocument = async ({
|
||||
documentId,
|
||||
recipients,
|
||||
requestMetadata,
|
||||
}: SetRecipientsForDocumentOptions) => {
|
||||
}: SetRecipientsForDocumentOptions): Promise<Recipient[]> => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
@ -226,5 +227,17 @@ export const setRecipientsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return persistedRecipients;
|
||||
// Filter out recipients that have been removed or have been updated.
|
||||
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
||||
const isRemoved = removedRecipients.find(
|
||||
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||
);
|
||||
const isUpdated = persistedRecipients.find(
|
||||
(persistedRecipient) => persistedRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
return [...filteredRecipients, ...persistedRecipients];
|
||||
};
|
||||
|
||||
44
packages/lib/types/webauthn.ts
Normal file
44
packages/lib/types/webauthn.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const ZClientExtensionResults = z.object({
|
||||
appid: z.boolean().optional(),
|
||||
credProps: z
|
||||
.object({
|
||||
rk: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
hmacCreateSecret: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ZAuthenticationResponseJSONSchema = z.object({
|
||||
id: z.string(),
|
||||
rawId: z.string(),
|
||||
response: z.object({
|
||||
clientDataJSON: z.string(),
|
||||
authenticatorData: z.string(),
|
||||
signature: z.string(),
|
||||
userHandle: z.string().optional(),
|
||||
}),
|
||||
authenticatorAttachment: z.union([z.literal('cross-platform'), z.literal('platform')]).optional(),
|
||||
clientExtensionResults: ZClientExtensionResults,
|
||||
type: z.literal('public-key'),
|
||||
});
|
||||
|
||||
export const ZRegistrationResponseJSONSchema = z.object({
|
||||
id: z.string(),
|
||||
rawId: z.string(),
|
||||
response: z.object({
|
||||
clientDataJSON: z.string(),
|
||||
attestationObject: z.string(),
|
||||
authenticatorData: z.string().optional(),
|
||||
transports: z.array(z.string()).optional(),
|
||||
publicKeyAlgorithm: z.number().optional(),
|
||||
publicKey: z.string().optional(),
|
||||
}),
|
||||
authenticatorAttachment: z.string().optional(),
|
||||
clientExtensionResults: ZClientExtensionResults.optional(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export type TAuthenticationResponseJSONSchema = z.infer<typeof ZAuthenticationResponseJSONSchema>;
|
||||
export type TRegistrationResponseJSONSchema = z.infer<typeof ZRegistrationResponseJSONSchema>;
|
||||
17
packages/lib/utils/authenticator.ts
Normal file
17
packages/lib/utils/authenticator.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { WEBAPP_BASE_URL } from '../constants/app';
|
||||
import { PASSKEY_TIMEOUT } from '../constants/auth';
|
||||
|
||||
/**
|
||||
* Extracts common fields to identify the RP (relying party)
|
||||
*/
|
||||
export const getAuthenticatorRegistrationOptions = () => {
|
||||
const webAppBaseUrl = new URL(WEBAPP_BASE_URL);
|
||||
const rpId = webAppBaseUrl.hostname;
|
||||
|
||||
return {
|
||||
rpName: 'Documenso',
|
||||
rpId,
|
||||
origin: WEBAPP_BASE_URL,
|
||||
timeout: PASSKEY_TIMEOUT,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_CREATED';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_DELETED';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_UPDATED';
|
||||
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'SIGN_IN_PASSKEY_FAIL';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Passkey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"credentialId" BYTEA NOT NULL,
|
||||
"credentialPublicKey" BYTEA NOT NULL,
|
||||
"counter" BIGINT NOT NULL,
|
||||
"credentialDeviceType" TEXT NOT NULL,
|
||||
"credentialBackedUp" BOOLEAN NOT NULL,
|
||||
"transports" TEXT[],
|
||||
|
||||
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnonymousVerificationToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AnonymousVerificationToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnonymousVerificationToken_id_key" ON "AnonymousVerificationToken"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnonymousVerificationToken_token_key" ON "AnonymousVerificationToken"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -52,6 +52,7 @@ model User {
|
||||
securityAuditLogs UserSecurityAuditLog[]
|
||||
Webhooks Webhook[]
|
||||
siteSettings SiteSettings[]
|
||||
passkeys Passkey[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@ -68,12 +69,16 @@ enum UserSecurityAuditLogType {
|
||||
ACCOUNT_SSO_LINK
|
||||
AUTH_2FA_DISABLE
|
||||
AUTH_2FA_ENABLE
|
||||
PASSKEY_CREATED
|
||||
PASSKEY_DELETED
|
||||
PASSKEY_UPDATED
|
||||
PASSWORD_RESET
|
||||
PASSWORD_UPDATE
|
||||
SIGN_OUT
|
||||
SIGN_IN
|
||||
SIGN_IN_FAIL
|
||||
SIGN_IN_2FA_FAIL
|
||||
SIGN_IN_PASSKEY_FAIL
|
||||
}
|
||||
|
||||
model UserSecurityAuditLog {
|
||||
@ -96,6 +101,30 @@ model PasswordResetToken {
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model Passkey {
|
||||
id String @id @default(cuid())
|
||||
userId Int
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
credentialId Bytes
|
||||
credentialPublicKey Bytes
|
||||
counter BigInt
|
||||
credentialDeviceType String
|
||||
credentialBackedUp Boolean
|
||||
transports String[]
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AnonymousVerificationToken {
|
||||
id String @id @unique @default(cuid())
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
id Int @id @default(autoincrement())
|
||||
identifier String
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { Document, Recipient } from '@documenso/prisma/client';
|
||||
import type {
|
||||
Document,
|
||||
DocumentData,
|
||||
DocumentMeta,
|
||||
Field,
|
||||
Recipient,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
export type DocumentWithRecipientAndSender = Omit<Document, 'document'> & {
|
||||
recipient: Recipient;
|
||||
@ -10,3 +16,10 @@ export type DocumentWithRecipientAndSender = Omit<Document, 'document'> & {
|
||||
subject: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type DocumentWithDetails = Document & {
|
||||
documentData: DocumentData;
|
||||
documentMeta: DocumentMeta | null;
|
||||
Recipient: Recipient[];
|
||||
Field: Field[];
|
||||
};
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import { createTRPCProxyClient, httpBatchLink, httpLink, splitLink } from '@trpc/client';
|
||||
import SuperJSON from 'superjson';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
import { AppRouter } from '../server/router';
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
transformer: SuperJSON,
|
||||
|
||||
links: [
|
||||
httpBatchLink({
|
||||
splitLink({
|
||||
condition: (op) => op.context.skipBatch === true,
|
||||
true: httpLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
false: httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import type { QueryClientConfig } from '@tanstack/react-query';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { httpBatchLink, httpLink, splitLink } from '@trpc/client';
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import SuperJSON from 'superjson';
|
||||
|
||||
@ -12,12 +12,22 @@ import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
import type { AppRouter } from '../server/router';
|
||||
|
||||
export { getQueryKey } from '@trpc/react-query';
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>({
|
||||
unstable_overrides: {
|
||||
overrides: {
|
||||
useMutation: {
|
||||
async onSuccess(opts) {
|
||||
await opts.originalFn();
|
||||
await opts.queryClient.invalidateQueries();
|
||||
|
||||
if (opts.meta.doNotInvalidateQueryOnMutation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidate all queries besides ones that specify not to in the meta data.
|
||||
await opts.queryClient.invalidateQueries({
|
||||
predicate: (query) => !query?.meta?.doNotInvalidateQueryOnMutation,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -55,9 +65,15 @@ export function TrpcProvider({ children }: TrpcProviderProps) {
|
||||
transformer: SuperJSON,
|
||||
|
||||
links: [
|
||||
httpBatchLink({
|
||||
splitLink({
|
||||
condition: (op) => op.context.skipBatch === true,
|
||||
true: httpLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
false: httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@ -1,15 +1,31 @@
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { parse } from 'cookie-es';
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
||||
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
|
||||
import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options';
|
||||
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
|
||||
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
|
||||
import {
|
||||
ZCreatePasskeyMutationSchema,
|
||||
ZDeletePasskeyMutationSchema,
|
||||
ZFindPasskeysQuerySchema,
|
||||
ZSignUpMutationSchema,
|
||||
ZUpdatePasskeyMutationSchema,
|
||||
ZVerifyPasswordMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
@ -78,4 +94,126 @@ export const authRouter = router({
|
||||
|
||||
return valid;
|
||||
}),
|
||||
|
||||
createPasskey: authenticatedProcedure
|
||||
.input(ZCreatePasskeyMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const verificationResponse = input.verificationResponse as RegistrationResponseJSON;
|
||||
|
||||
return await createPasskey({
|
||||
userId: ctx.user.id,
|
||||
verificationResponse,
|
||||
passkeyName: input.passkeyName,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
return await createPasskeyRegistrationOptions({
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'We were unable to create the registration options for the passkey. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => {
|
||||
const sessionIdToken = parse(ctx.req.headers.cookie ?? '')['next-auth.csrf-token'];
|
||||
|
||||
if (!sessionIdToken) {
|
||||
throw new Error('Missing CSRF token');
|
||||
}
|
||||
|
||||
const [sessionId] = decodeURI(sessionIdToken).split('|');
|
||||
|
||||
try {
|
||||
return await createPasskeySigninOptions({ sessionId });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to create the options for passkey signin. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
deletePasskey: authenticatedProcedure
|
||||
.input(ZDeletePasskeyMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { passkeyId } = input;
|
||||
|
||||
await deletePasskey({
|
||||
userId: ctx.user.id,
|
||||
passkeyId,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to delete this passkey. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
findPasskeys: authenticatedProcedure
|
||||
.input(ZFindPasskeysQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { page, perPage, orderBy } = input;
|
||||
|
||||
return await findPasskeys({
|
||||
page,
|
||||
perPage,
|
||||
orderBy,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to find passkeys. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updatePasskey: authenticatedProcedure
|
||||
.input(ZUpdatePasskeyMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { passkeyId, name } = input;
|
||||
|
||||
await updatePasskey({
|
||||
userId: ctx.user.id,
|
||||
passkeyId,
|
||||
name,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to update this passkey. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||
|
||||
export const ZCurrentPasswordSchema = z
|
||||
.string()
|
||||
.min(6, { message: 'Must be at least 6 characters in length' })
|
||||
@ -32,6 +35,29 @@ export const ZSignUpMutationSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZCreatePasskeyMutationSchema = z.object({
|
||||
passkeyName: z.string().trim().min(1),
|
||||
verificationResponse: ZRegistrationResponseJSONSchema,
|
||||
});
|
||||
|
||||
export const ZDeletePasskeyMutationSchema = z.object({
|
||||
passkeyId: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const ZUpdatePasskeyMutationSchema = z.object({
|
||||
passkeyId: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const ZFindPasskeysQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
||||
orderBy: z
|
||||
.object({
|
||||
column: z.enum(['createdAt', 'updatedAt', 'name']),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||
|
||||
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
||||
|
||||
@ -9,6 +9,7 @@ import { duplicateDocumentById } from '@documenso/lib/server-only/document/dupli
|
||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
@ -23,6 +24,7 @@ import {
|
||||
ZFindDocumentAuditLogsQuerySchema,
|
||||
ZGetDocumentByIdQuerySchema,
|
||||
ZGetDocumentByTokenQuerySchema,
|
||||
ZGetDocumentWithDetailsByIdQuerySchema,
|
||||
ZResendDocumentMutationSchema,
|
||||
ZSearchDocumentsMutationSchema,
|
||||
ZSendDocumentMutationSchema,
|
||||
@ -66,6 +68,24 @@ export const documentRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
getDocumentWithDetailsById: authenticatedProcedure
|
||||
.input(ZGetDocumentWithDetailsByIdQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await getDocumentWithDetailsById({
|
||||
...input,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to find this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
createDocument: authenticatedProcedure
|
||||
.input(ZCreateDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -29,6 +29,15 @@ export const ZGetDocumentByTokenQuerySchema = z.object({
|
||||
|
||||
export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQuerySchema>;
|
||||
|
||||
export const ZGetDocumentWithDetailsByIdQuerySchema = z.object({
|
||||
id: z.number().min(1),
|
||||
teamId: z.number().min(1).optional(),
|
||||
});
|
||||
|
||||
export type TGetDocumentWithDetailsByIdQuerySchema = z.infer<
|
||||
typeof ZGetDocumentWithDetailsByIdQuerySchema
|
||||
>;
|
||||
|
||||
export const ZCreateDocumentMutationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
documentDataId: z.string().min(1),
|
||||
|
||||
@ -1,33 +1,33 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
|
||||
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
|
||||
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
|
||||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZDisableTwoFactorAuthenticationMutationSchema,
|
||||
ZEnableTwoFactorAuthenticationMutationSchema,
|
||||
ZSetupTwoFactorAuthenticationMutationSchema,
|
||||
ZViewRecoveryCodesMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const twoFactorAuthenticationRouter = router({
|
||||
setup: authenticatedProcedure
|
||||
.input(ZSetupTwoFactorAuthenticationMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password } = input;
|
||||
|
||||
setup: authenticatedProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
return await setupTwoFactorAuthentication({
|
||||
user,
|
||||
password,
|
||||
user: ctx.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to setup two-factor authentication. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
enable: authenticatedProcedure
|
||||
@ -44,7 +44,11 @@ export const twoFactorAuthenticationRouter = router({
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@ -59,16 +63,17 @@ export const twoFactorAuthenticationRouter = router({
|
||||
try {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password, backupCode } = input;
|
||||
|
||||
return await disableTwoFactorAuthentication({
|
||||
user,
|
||||
password,
|
||||
backupCode,
|
||||
token: input.token,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@ -81,38 +86,18 @@ export const twoFactorAuthenticationRouter = router({
|
||||
.input(ZViewRecoveryCodesMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password } = input;
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED,
|
||||
return await viewBackupCodes({
|
||||
user: ctx.user,
|
||||
token: input.token,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.password || !compareSync(password, user.password)) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: ErrorCode.INCORRECT_PASSWORD,
|
||||
});
|
||||
}
|
||||
|
||||
const recoveryCodes = await getBackupCodes({ user });
|
||||
|
||||
return { recoveryCodes };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to view your recovery codes. Please try again later.',
|
||||
});
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TSetupTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
typeof ZSetupTwoFactorAuthenticationMutationSchema
|
||||
>;
|
||||
|
||||
export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
});
|
||||
@ -17,8 +9,7 @@ export type TEnableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
>;
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string().trim(),
|
||||
token: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
@ -26,7 +17,7 @@ export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
>;
|
||||
|
||||
export const ZViewRecoveryCodesMutationSchema = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
token: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>;
|
||||
|
||||
@ -230,7 +230,7 @@ export const AddSubjectFormPartial = ({
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="redirectUrl" className="flex items-center">
|
||||
Redirect URL{' '}
|
||||
Redirect URL
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="mx-2 h-4 w-4" />
|
||||
|
||||
Reference in New Issue
Block a user