mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
17 Commits
exp/millio
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| fbfaca190b | |||
| 486b1cbf62 | |||
| 16fb90f4d2 | |||
| 53cb38a394 | |||
| 073a050587 | |||
| 39c01f4e8d | |||
| 335684d0b7 | |||
| 792158c2cb | |||
| 83153cee32 | |||
| 2d2bdc536e | |||
| c16c36a1fc | |||
| 1d79ebbda3 | |||
| 252dd0008c | |||
| 35d0fed8b3 | |||
| dad56b4929 | |||
| 7e4c44e820 | |||
| adc97802ea |
@ -2,6 +2,11 @@
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# [[CRYPTO]]
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# This should be a random string of at least 32 characters
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||
|
||||
# [[AUTH OPTIONAL]]
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
@ -240,7 +240,7 @@ Now you can install the dependencies and build it:
|
||||
|
||||
```
|
||||
npm i
|
||||
npm run:build:web
|
||||
npm run build:web
|
||||
npm run prisma:migrate-deploy
|
||||
```
|
||||
|
||||
|
||||
@ -20,8 +20,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
experimental: {
|
||||
serverActionsBodySizeLimit: '10mb',
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -24,8 +24,8 @@
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"next-contentlayer": "^0.3.4",
|
||||
"next-plausible": "^3.10.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
@ -44,5 +44,13 @@
|
||||
"@types/node": "20.1.0",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7"
|
||||
},
|
||||
"overrides": {
|
||||
"next-auth": {
|
||||
"next": "$next"
|
||||
},
|
||||
"next-contentlayer": {
|
||||
"next": "$next"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
|
||||
@ -22,7 +22,9 @@ const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverActionsBodySizeLimit: '50mb',
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -27,8 +27,8 @@
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
@ -44,6 +44,7 @@
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
"uqr": "^0.1.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -52,5 +53,13 @@
|
||||
"@types/node": "20.1.0",
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7"
|
||||
},
|
||||
"overrides": {
|
||||
"next-auth": {
|
||||
"next": "$next"
|
||||
},
|
||||
"next-contentlayer": {
|
||||
"next": "$next"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,28 +4,28 @@ import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
|
||||
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
||||
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
||||
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
||||
|
||||
export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
@ -35,7 +35,7 @@ export type EditDocumentFormProps = {
|
||||
documentData: DocumentData;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||
|
||||
export const EditDocumentForm = ({
|
||||
className,
|
||||
@ -48,30 +48,65 @@ export const EditDocumentForm = ({
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||
const [step, setStep] = useState<EditDocumentStep>(
|
||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
||||
);
|
||||
|
||||
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 documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||
title: {
|
||||
title: 'Add Title',
|
||||
description: 'Add the title to the document.',
|
||||
stepIndex: 1,
|
||||
},
|
||||
signers: {
|
||||
title: 'Add Signers',
|
||||
description: 'Add the people who will sign the document.',
|
||||
stepIndex: 1,
|
||||
stepIndex: 2,
|
||||
onBackStep: () => document.status === DocumentStatus.DRAFT && setStep('title'),
|
||||
},
|
||||
fields: {
|
||||
title: 'Add Fields',
|
||||
description: 'Add all relevant fields for each recipient.',
|
||||
stepIndex: 2,
|
||||
stepIndex: 3,
|
||||
onBackStep: () => setStep('signers'),
|
||||
},
|
||||
subject: {
|
||||
title: 'Add Subject',
|
||||
description: 'Add the subject and message you wish to send to signers.',
|
||||
stepIndex: 3,
|
||||
stepIndex: 4,
|
||||
onBackStep: () => setStep('fields'),
|
||||
},
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addTitle({
|
||||
documentId: document.id,
|
||||
title: data.title,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
setStep('signers');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while updating title.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
@ -120,7 +155,7 @@ export const EditDocumentForm = ({
|
||||
const { subject, message } = data.email;
|
||||
|
||||
try {
|
||||
await completeDocument({
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
email: {
|
||||
subject,
|
||||
@ -158,16 +193,32 @@ export const EditDocumentForm = ({
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
||||
<DocumentFlowFormContainer
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
{step === 'title' && (
|
||||
<AddTitleFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.title}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
onSubmit={onAddTitleFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'signers' && (
|
||||
<AddSignersFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
numberOfSteps={Object.keys(documentFlow).length}
|
||||
|
||||
@ -43,11 +43,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { documentData } = document;
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
await getRecipientsForDocument({
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
await getFieldsForDocument({
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
|
||||
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
<h3 className="text-2xl font-semibold">Billing</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
|
||||
@ -1,19 +1,5 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function PasswordSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Password</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
export default function PasswordSettingsPage() {
|
||||
redirect('/settings/security');
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile</h3>
|
||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
|
||||
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Here you can manage your password and security settings.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
|
||||
<hr className="mb-4 mt-8" />
|
||||
|
||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Add and manage your two factor security settings to add an extra layer of security to your
|
||||
account!
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Two-factor methods</h5>
|
||||
|
||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Recovery methods</h5>
|
||||
|
||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
@ -53,6 +54,9 @@ export default async function CompletedSigningPage({
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
recipient.email;
|
||||
|
||||
const sessionData = await getServerSession();
|
||||
const isLoggedIn = !!sessionData?.user;
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||
{/* Card with recipient */}
|
||||
@ -105,15 +109,21 @@ export default async function CompletedSigningPage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want to send slick signing links like this one?{' '}
|
||||
<Link
|
||||
href="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
{isLoggedIn ? (
|
||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||
Go Back Home
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want to send slick signing links like this one?{' '}
|
||||
<Link
|
||||
href="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -7,9 +7,9 @@ import { useRouter } from 'next/navigation';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -34,6 +34,9 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
|
||||
@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
@ -76,10 +76,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
|
||||
value,
|
||||
isBase64: true,
|
||||
});
|
||||
|
||||
|
||||
@ -69,15 +69,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Waiting</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
</div>
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
Key,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
Monitor,
|
||||
@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/password" className="cursor-pointer">
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Password
|
||||
<Link href="/settings/security" className="cursor-pointer">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Security
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/password">
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Password
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
Security
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Key, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/password">
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Password
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
Security
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'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="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||
<div className="flex-1">
|
||||
<p>Authenticator app</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||
Create one-time passwords that serve as a secondary authentication method for confirming
|
||||
your identity when requested during the sign-in process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isTwoFactorEnabled ? (
|
||||
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setModalState('enable')} size="sm">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnableAuthenticatorAppDialog
|
||||
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||
open={isEnableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
|
||||
<DisableAuthenticatorAppDialog
|
||||
key={isDisableDialogOpen ? 'open' : 'closed'}
|
||||
open={isDisableDialogOpen}
|
||||
onOpenChange={(open) => !open && setModalState(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,161 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string(),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
||||
typeof ZDisableTwoFactorAuthenticationForm
|
||||
>;
|
||||
|
||||
export type DisableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DisableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DisableAuthenticatorAppDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
||||
trpc.twoFactorAuthentication.disable.useMutation();
|
||||
|
||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
||||
disableTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
backupCode,
|
||||
}: TDisableTwoFactorAuthenticationForm) => {
|
||||
try {
|
||||
await disableTwoFactorAuthentication({ password, backupCode });
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication disabled',
|
||||
description:
|
||||
'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.',
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to disable two-factor authentication',
|
||||
description:
|
||||
'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Authenticator App</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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...disableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onDisableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,283 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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';
|
||||
|
||||
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({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||
|
||||
export type EnableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EnableAuthenticatorAppDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.setup.useMutation();
|
||||
|
||||
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||
setupTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
||||
enableTwoFactorAuthenticationForm.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) => {
|
||||
try {
|
||||
await setupTwoFactorAuthentication({ password });
|
||||
} 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 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 onCompleteClick = () => {
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||
|
||||
{step === 'setup' && (
|
||||
<DialogDescription>
|
||||
To enable two-factor authentication, 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('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>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
To enable two-factor authentication, scan the following QR code using your
|
||||
authenticator app.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-36 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If your authenticator app does not support QR codes, you can use the following
|
||||
code instead:
|
||||
</p>
|
||||
|
||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||
{setupTwoFactorAuthenticationData?.secret}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Once you have scanned the QR code or entered the code manually, enter the code
|
||||
provided by your authenticator app below.
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
control={enableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
))
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||
<Button type="button" onClick={() => onCompleteClick()}>
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type RecoveryCodeListProps = {
|
||||
recoveryCodes: string[];
|
||||
};
|
||||
|
||||
export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => {
|
||||
const { toast } = useToast();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const onCopyRecoveryCodeClick = async (code: string) => {
|
||||
try {
|
||||
const result = await copyToClipboard(code);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Unable to copy recovery code');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Recovery code copied',
|
||||
description: 'Your recovery code has been copied to your clipboard.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to copy recovery code',
|
||||
description:
|
||||
'We were unable to copy your recovery code to your clipboard. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{recoveryCodes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
|
||||
>
|
||||
<span>{code}</span>
|
||||
|
||||
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
|
||||
<button
|
||||
className="opacity-60 hover:opacity-80"
|
||||
onClick={() => void onCopyRecoveryCodeClick(code)}
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||
|
||||
type RecoveryCodesProps = {
|
||||
// backupCodes: string[] | null;
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||
<div className="flex-1">
|
||||
<p>Recovery Codes</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||
Recovery codes are used to access your account in the event that you lose access to your
|
||||
authenticator app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
||||
View Codes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ViewRecoveryCodesDialog
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZViewRecoveryCodesForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
||||
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
||||
return 'authenticate';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<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 (
|
||||
<Form {...viewRecoveryCodesForm}>
|
||||
<form
|
||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={viewRecoveryCodesForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{viewRecoveryCodesData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,30 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
|
||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
await setFieldsForDocument({
|
||||
userId: user.id,
|
||||
documentId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
pageWidth: field.pageWidth,
|
||||
pageHeight: field.pageHeight,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@ -1,25 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
|
||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
await setRecipientsForDocument({
|
||||
userId: user.id,
|
||||
documentId,
|
||||
recipients: signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
email: signer.email,
|
||||
name: signer.name,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@ -1,29 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
|
||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
||||
'use server';
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
if (email.message || email.subject) {
|
||||
await upsertDocumentMeta({
|
||||
documentId,
|
||||
subject: email.subject,
|
||||
message: email.message,
|
||||
});
|
||||
}
|
||||
|
||||
return await sendDocument({
|
||||
userId: user.id,
|
||||
documentId,
|
||||
});
|
||||
};
|
||||
@ -3,7 +3,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@ -12,23 +11,30 @@ import { z } from 'zod';
|
||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Input, PasswordInput } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||
'This account appears to be using a social login method, please sign in using that method',
|
||||
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
||||
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
||||
};
|
||||
|
||||
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||
|
||||
const LOGIN_REDIRECT_PATH = '/documents';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
password: z.string().min(6, { message: 'Invalid password' }).max(72),
|
||||
password: z.string().min(6).max(72),
|
||||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||
@ -39,33 +45,84 @@ export type SignInFormProps = {
|
||||
|
||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
password: '',
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
},
|
||||
resolver: zodResolver(ZSignInFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
||||
const onCloseTwoFactorAuthenticationDialog = () => {
|
||||
setValue('totpCode', '');
|
||||
setValue('backupCode', '');
|
||||
|
||||
setIsTwoFactorAuthenticationDialogOpen(false);
|
||||
};
|
||||
|
||||
const onToggleTwoFactorAuthenticationMethodClick = () => {
|
||||
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
|
||||
|
||||
if (method === 'totp') {
|
||||
setValue('backupCode', '');
|
||||
}
|
||||
|
||||
if (method === 'backup') {
|
||||
setValue('totpCode', '');
|
||||
}
|
||||
|
||||
setTwoFactorAuthenticationMethod(method);
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
const credentials: Record<string, string> = {
|
||||
email,
|
||||
password,
|
||||
};
|
||||
|
||||
if (totpCode) {
|
||||
credentials.totpCode = totpCode;
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
credentials.backupCode = backupCode;
|
||||
}
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
...credentials,
|
||||
|
||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error && isErrorCode(result.error)) {
|
||||
if (result.error === TwoFactorEnabledErrorCode) {
|
||||
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = ERROR_MESSAGES[result.error];
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: ERROR_MESSAGES[result.error],
|
||||
title: 'Unable to sign in',
|
||||
description: errorMessage ?? 'An unknown error occurred',
|
||||
});
|
||||
|
||||
return;
|
||||
@ -118,31 +175,14 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
className="bg-background mt-2"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={isTwoFactorAuthenticationDialogOpen}
|
||||
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
{twoFactorAuthenticationMethod === 'totp' && (
|
||||
<div>
|
||||
<Label htmlFor="totpCode" className="text-muted-forground">
|
||||
Authentication Token
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="totpCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('totpCode')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorAuthenticationMethod === 'backup' && (
|
||||
<div>
|
||||
<Label htmlFor="backupCode" className="text-muted-forground">
|
||||
Backup Code
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="backupCode"
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
{...register('backupCode')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||
>
|
||||
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
5574
package-lock.json
generated
5574
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -19,6 +19,7 @@
|
||||
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
|
||||
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
||||
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
||||
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
|
||||
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||
"with:env": "dotenv -e .env -e .env.local --",
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
||||
@ -46,8 +47,13 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"recharts": "^2.7.2",
|
||||
"react-hotkeys-hook": "^4.4.1"
|
||||
"dependencies": {},
|
||||
"overrides": {
|
||||
"next-auth": {
|
||||
"next": "14.0.3"
|
||||
},
|
||||
"next-contentlayer": {
|
||||
"next": "14.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,8 +17,8 @@
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"react": "18.2.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
17
packages/email/components.ts
Normal file
17
packages/email/components.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export * from '@react-email/body';
|
||||
export * from '@react-email/button';
|
||||
export * from '@react-email/column';
|
||||
export * from '@react-email/container';
|
||||
export * from '@react-email/font';
|
||||
export * from '@react-email/head';
|
||||
export * from '@react-email/heading';
|
||||
export * from '@react-email/hr';
|
||||
export * from '@react-email/html';
|
||||
export * from '@react-email/img';
|
||||
export * from '@react-email/link';
|
||||
export * from '@react-email/preview';
|
||||
export * from '@react-email/render';
|
||||
export * from '@react-email/row';
|
||||
export * from '@react-email/section';
|
||||
export * from '@react-email/tailwind';
|
||||
export * from '@react-email/text';
|
||||
@ -18,8 +18,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "2.0.0",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"@react-email/tailwind": "0.0.9",
|
||||
"@react-email/body": "0.0.4",
|
||||
"@react-email/button": "0.0.11",
|
||||
"@react-email/column": "0.0.8",
|
||||
"@react-email/container": "0.0.10",
|
||||
"@react-email/font": "0.0.4",
|
||||
"@react-email/head": "0.0.6",
|
||||
"@react-email/heading": "0.0.9",
|
||||
"@react-email/hr": "0.0.6",
|
||||
"@react-email/html": "0.0.6",
|
||||
"@react-email/img": "0.0.6",
|
||||
"@react-email/link": "0.0.6",
|
||||
"@react-email/preview": "0.0.7",
|
||||
"@react-email/render": "0.0.9",
|
||||
"@react-email/row": "0.0.6",
|
||||
"@react-email/section": "0.0.10",
|
||||
"@react-email/tailwind": "0.0.13-canary.1",
|
||||
"@react-email/text": "0.0.6",
|
||||
"nodemailer": "^6.9.3",
|
||||
"react-email": "^1.9.5",
|
||||
"resend": "^2.0.0"
|
||||
@ -29,8 +44,5 @@
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
"tsup": "^7.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@react-email/tailwind": "0.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { render } from '@react-email/components';
|
||||
export { render, renderAsync } from '@react-email/render';
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateConfirmationEmailProps = {
|
||||
@ -14,15 +11,7 @@ export const TemplateConfirmationEmail = ({
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
@ -47,6 +36,6 @@ export const TemplateConfirmationEmail = ({
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Button, Column, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { Button, Column, Img, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateDocumentCompletedProps {
|
||||
@ -20,15 +17,7 @@ export const TemplateDocumentCompleted = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
@ -72,7 +61,7 @@ export const TemplateDocumentCompleted = ({
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Column, Img, Row, Section } from '@react-email/components';
|
||||
import { Column, Img, Row, Section } from '../components';
|
||||
|
||||
export interface TemplateDocumentImageProps {
|
||||
assetBaseUrl: string;
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateDocumentInviteProps {
|
||||
@ -19,15 +16,7 @@ export const TemplateDocumentInvite = ({
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
@ -49,7 +38,7 @@ export const TemplateDocumentInvite = ({
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Column, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { Column, Img, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateDocumentPendingProps {
|
||||
@ -18,15 +15,7 @@ export const TemplateDocumentPending = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
@ -52,7 +41,7 @@ export const TemplateDocumentPending = ({
|
||||
We'll notify you as soon as it's ready.
|
||||
</Text>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Button, Column, Img, Link, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { Button, Column, Img, Link, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateDocumentSelfSignedProps {
|
||||
@ -20,15 +17,7 @@ export const TemplateDocumentSelfSigned = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
@ -84,7 +73,7 @@ export const TemplateDocumentSelfSigned = ({
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
import { Link, Section, Text } from '../components';
|
||||
|
||||
export type TemplateFooterProps = {
|
||||
isDocument?: boolean;
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateForgotPasswordProps = {
|
||||
@ -14,15 +11,7 @@ export const TemplateForgotPassword = ({
|
||||
assetBaseUrl,
|
||||
}: TemplateForgotPasswordProps) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
@ -43,7 +32,7 @@ export const TemplateForgotPassword = ({
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateResetPasswordProps {
|
||||
@ -12,15 +9,7 @@ export interface TemplateResetPasswordProps {
|
||||
|
||||
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
@ -41,7 +30,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateConfirmationEmail,
|
||||
TemplateConfirmationEmailProps,
|
||||
} from '../template-components/template-confirmation-email';
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||
import type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
|
||||
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export const ConfirmEmailTemplate = ({
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateDocumentCompleted,
|
||||
TemplateDocumentCompletedProps,
|
||||
} from '../template-components/template-document-completed';
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||
import type { TemplateDocumentCompletedProps } from '../template-components/template-document-completed';
|
||||
import { TemplateDocumentCompleted } from '../template-components/template-document-completed';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
@ -10,14 +12,9 @@ import {
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateDocumentInvite,
|
||||
TemplateDocumentInviteProps,
|
||||
} from '../template-components/template-document-invite';
|
||||
} from '../components';
|
||||
import type { TemplateDocumentInviteProps } from '../template-components/template-document-invite';
|
||||
import { TemplateDocumentInvite } from '../template-components/template-document-invite';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateDocumentPending,
|
||||
TemplateDocumentPendingProps,
|
||||
} from '../template-components/template-document-pending';
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||
import type { TemplateDocumentPendingProps } from '../template-components/template-document-pending';
|
||||
import { TemplateDocumentPending } from '../template-components/template-document-pending';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentPendingEmailTemplateProps = Partial<TemplateDocumentPendingProps>;
|
||||
|
||||
@ -1,20 +1,8 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateDocumentSelfSigned,
|
||||
TemplateDocumentSelfSignedProps,
|
||||
} from '../template-components/template-document-self-signed';
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||
import type { TemplateDocumentSelfSignedProps } from '../template-components/template-document-self-signed';
|
||||
import { TemplateDocumentSelfSigned } from '../template-components/template-document-self-signed';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps;
|
||||
|
||||
@ -1,21 +1,9 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import {
|
||||
TemplateForgotPassword,
|
||||
TemplateForgotPasswordProps,
|
||||
} from '../template-components/template-forgot-password';
|
||||
import type { TemplateForgotPasswordProps } from '../template-components/template-forgot-password';
|
||||
import { TemplateForgotPassword } from '../template-components/template-forgot-password';
|
||||
|
||||
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
@ -10,15 +12,10 @@ import {
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import {
|
||||
TemplateResetPassword,
|
||||
TemplateResetPasswordProps,
|
||||
} from '../template-components/template-reset-password';
|
||||
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
|
||||
import { TemplateResetPassword } from '../template-components/template-reset-password';
|
||||
|
||||
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
|
||||
|
||||
|
||||
1
packages/lib/constants/crypto.ts
Normal file
1
packages/lib/constants/crypto.ts
Normal file
@ -0,0 +1 @@
|
||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||
@ -10,6 +10,8 @@ import GoogleProvider from 'next-auth/providers/google';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
@ -25,13 +27,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
totpCode: {
|
||||
label: 'Two-factor Code',
|
||||
type: 'input',
|
||||
placeholder: 'Code from authenticator app',
|
||||
},
|
||||
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||
},
|
||||
authorize: async (credentials, _req) => {
|
||||
if (!credentials) {
|
||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||
}
|
||||
|
||||
const { email, password } = credentials;
|
||||
const { email, password, backupCode, totpCode } = credentials;
|
||||
|
||||
const user = await getUserByEmail({ email }).catch(() => {
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
@ -47,6 +55,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
}
|
||||
|
||||
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||
|
||||
if (is2faEnabled) {
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
totpCode
|
||||
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
|
||||
@ -8,4 +8,15 @@ export const ErrorCode = {
|
||||
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
||||
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
||||
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
||||
INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR',
|
||||
TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED',
|
||||
TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED',
|
||||
TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET',
|
||||
TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||
INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE',
|
||||
INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||
INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER',
|
||||
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
|
||||
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
|
||||
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
|
||||
} as const;
|
||||
|
||||
@ -25,6 +25,8 @@
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
@ -32,8 +34,9 @@
|
||||
"bcrypt": "^5.1.0",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"next": "14.0.3",
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
|
||||
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { compare } from 'bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
user,
|
||||
password,
|
||||
}: 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 });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
|
||||
type EnableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const enableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
code,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
|
||||
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
return { recoveryCodes };
|
||||
};
|
||||
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
interface GetBackupCodesOptions {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const ZBackupCodeSchema = z.array(z.string());
|
||||
|
||||
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new Error('User has not enabled 2FA');
|
||||
}
|
||||
|
||||
if (!user.twoFactorBackupCodes) {
|
||||
throw new Error('User has no backup codes');
|
||||
}
|
||||
|
||||
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const data = JSON.parse(secret);
|
||||
|
||||
const result = ZBackupCodeSchema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
|
||||
type IsTwoFactorAuthenticationEnabledOptions = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const isTwoFactorAuthenticationEnabled = ({
|
||||
user,
|
||||
}: IsTwoFactorAuthenticationEnabledOptions) => {
|
||||
return (
|
||||
user.twoFactorEnabled &&
|
||||
user.identityProvider === 'DOCUMENSO' &&
|
||||
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
|
||||
);
|
||||
};
|
||||
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { compare } from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
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;
|
||||
|
||||
if (!key) {
|
||||
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 = new Array(10)
|
||||
.fill(null)
|
||||
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||
|
||||
const accountName = user.email;
|
||||
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
|
||||
const encodedSecret = base32.encode(secret);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: symmetricEncrypt({
|
||||
data: JSON.stringify(backupCodes),
|
||||
key: key,
|
||||
}),
|
||||
twoFactorSecret: symmetricEncrypt({
|
||||
data: encodedSecret,
|
||||
key: key,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
secret: encodedSecret,
|
||||
uri,
|
||||
};
|
||||
};
|
||||
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
import { verifyBackupCode } from './verify-backup-code';
|
||||
|
||||
type ValidateTwoFactorAuthenticationOptions = {
|
||||
totpCode?: string;
|
||||
backupCode?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const validateTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
totpCode,
|
||||
user,
|
||||
}: ValidateTwoFactorAuthenticationOptions) => {
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
|
||||
}
|
||||
|
||||
if (totpCode) {
|
||||
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
return await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
|
||||
};
|
||||
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { TOTPController } from 'oslo/otp';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
const totp = new TOTPController();
|
||||
|
||||
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||
user: User;
|
||||
totpCode: string;
|
||||
};
|
||||
|
||||
export const verifyTwoFactorAuthenticationToken = async ({
|
||||
user,
|
||||
totpCode,
|
||||
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error('user missing 2fa secret');
|
||||
}
|
||||
|
||||
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||
|
||||
return isValidToken;
|
||||
};
|
||||
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
|
||||
type VerifyBackupCodeParams = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
};
|
||||
|
||||
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
|
||||
const userBackupCodes = await getBackupCodes({ user });
|
||||
|
||||
if (!userBackupCodes) {
|
||||
throw new Error('User has no backup codes');
|
||||
}
|
||||
|
||||
return userBackupCodes.includes(backupCode);
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import { hashSync as bcryptHashSync } from 'bcrypt';
|
||||
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
|
||||
@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
|
||||
export const hashSync = (password: string) => {
|
||||
return bcryptHashSync(password, SALT_ROUNDS);
|
||||
};
|
||||
|
||||
export const compareSync = (password: string, hash: string) => {
|
||||
return bcryptCompareSync(password, hash);
|
||||
};
|
||||
|
||||
@ -57,7 +57,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name } = recipient;
|
||||
|
||||
@ -95,5 +95,5 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
);
|
||||
};
|
||||
|
||||
@ -32,7 +32,7 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
||||
|
||||
const buffer = await getFile(document.documentData);
|
||||
|
||||
await Promise.all([
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name, token } = recipient;
|
||||
|
||||
@ -64,5 +64,5 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
||||
],
|
||||
});
|
||||
}),
|
||||
]);
|
||||
);
|
||||
};
|
||||
|
||||
@ -45,7 +45,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name } = recipient;
|
||||
|
||||
@ -96,7 +96,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
},
|
||||
});
|
||||
}),
|
||||
]);
|
||||
);
|
||||
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: {
|
||||
|
||||
21
packages/lib/server-only/document/update-title.ts
Normal file
21
packages/lib/server-only/document/update-title.ts
Normal file
@ -0,0 +1,21 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdateTitleOptions = {
|
||||
userId: number;
|
||||
documentId: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => {
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -54,6 +54,7 @@ export const signFieldWithToken = async ({
|
||||
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
|
||||
|
||||
let customText = !isSignatureField ? value : undefined;
|
||||
|
||||
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
|
||||
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
|
||||
|
||||
@ -61,29 +62,48 @@ export const signFieldWithToken = async ({
|
||||
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
|
||||
}
|
||||
|
||||
await prisma.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
},
|
||||
data: {
|
||||
customText,
|
||||
inserted: true,
|
||||
Signature: isSignatureField
|
||||
? {
|
||||
upsert: {
|
||||
create: {
|
||||
recipientId: field.recipientId,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
},
|
||||
update: {
|
||||
recipientId: field.recipientId,
|
||||
signatureImageAsBase64,
|
||||
typedSignature,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
|
||||
throw new Error('Signature field must have a signature');
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const updatedField = await tx.field.update({
|
||||
where: {
|
||||
id: field.id,
|
||||
},
|
||||
data: {
|
||||
customText,
|
||||
inserted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isSignatureField) {
|
||||
if (!field.recipientId) {
|
||||
throw new Error('Field has no recipientId');
|
||||
}
|
||||
|
||||
const signature = await tx.signature.upsert({
|
||||
where: {
|
||||
fieldId: field.id,
|
||||
},
|
||||
create: {
|
||||
fieldId: field.id,
|
||||
recipientId: field.recipientId,
|
||||
signatureImageAsBase64: signatureImageAsBase64,
|
||||
typedSignature: typedSignature,
|
||||
},
|
||||
update: {
|
||||
signatureImageAsBase64: signatureImageAsBase64,
|
||||
typedSignature: typedSignature,
|
||||
},
|
||||
});
|
||||
|
||||
// Dirty but I don't want to deal with type information
|
||||
Object.assign(updatedField, {
|
||||
Signature: signature,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedField;
|
||||
});
|
||||
};
|
||||
|
||||
@ -32,7 +32,7 @@ export const findUsers = async ({
|
||||
});
|
||||
|
||||
const [users, count] = await Promise.all([
|
||||
await prisma.user.findMany({
|
||||
prisma.user.findMany({
|
||||
include: {
|
||||
Subscription: true,
|
||||
Document: {
|
||||
@ -45,7 +45,7 @@ export const findUsers = async ({
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
await prisma.user.count({
|
||||
prisma.user.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
32
packages/lib/universal/crypto.ts
Normal file
32
packages/lib/universal/crypto.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
|
||||
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
|
||||
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
|
||||
export type SymmetricEncryptOptions = {
|
||||
key: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
|
||||
const keyAsBytes = sha256(key);
|
||||
const dataAsBytes = utf8ToBytes(data);
|
||||
|
||||
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
|
||||
|
||||
return bytesToHex(chacha.encrypt(dataAsBytes));
|
||||
};
|
||||
|
||||
export type SymmetricDecryptOptions = {
|
||||
key: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
|
||||
const keyAsBytes = sha256(key);
|
||||
const dataAsBytes = hexToBytes(data);
|
||||
|
||||
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
|
||||
|
||||
return chacha.decrypt(dataAsBytes);
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "twoFactorBackupCodes" TEXT,
|
||||
ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "twoFactorSecret" TEXT;
|
||||
@ -19,25 +19,28 @@ enum Role {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
Subscription Subscription?
|
||||
PasswordResetToken PasswordResetToken[]
|
||||
VerificationToken VerificationToken[]
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
Subscription Subscription?
|
||||
PasswordResetToken PasswordResetToken[]
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
VerificationToken VerificationToken[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
"dependencies": {
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"tailwindcss": "3.3.2",
|
||||
"tailwindcss-animate": "^1.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -21,5 +21,6 @@
|
||||
"superjson": "^1.13.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||
|
||||
import { procedure, router } from '../trpc';
|
||||
import { ZSignUpMutationSchema } from './schema';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
|
||||
|
||||
export const authRouter = router({
|
||||
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
||||
@ -30,4 +32,23 @@ export const authRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
verifyPassword: authenticatedProcedure
|
||||
.input(ZVerifyPasswordMutationSchema)
|
||||
.mutation(({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password } = input;
|
||||
|
||||
if (!user.password) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: ErrorCode.INCORRECT_PASSWORD,
|
||||
});
|
||||
}
|
||||
|
||||
const valid = compareSync(password, user.password);
|
||||
|
||||
return valid;
|
||||
}),
|
||||
});
|
||||
|
||||
@ -8,3 +8,5 @@ export const ZSignUpMutationSchema = z.object({
|
||||
});
|
||||
|
||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||
|
||||
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
|
||||
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
|
||||
@ -8,6 +9,7 @@ import { getDocumentById } from '@documenso/lib/server-only/document/get-documen
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
|
||||
@ -21,6 +23,7 @@ import {
|
||||
ZSendDocumentMutationSchema,
|
||||
ZSetFieldsForDocumentMutationSchema,
|
||||
ZSetRecipientsForDocumentMutationSchema,
|
||||
ZSetTitleForDocumentMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const documentRouter = router({
|
||||
@ -113,6 +116,20 @@ export const documentRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
setTitleForDocument: authenticatedProcedure
|
||||
.input(ZSetTitleForDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { documentId, title } = input;
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
return await updateTitle({
|
||||
title,
|
||||
userId,
|
||||
documentId,
|
||||
});
|
||||
}),
|
||||
|
||||
setRecipientsForDocument: authenticatedProcedure
|
||||
.input(ZSetRecipientsForDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@ -160,7 +177,15 @@ export const documentRouter = router({
|
||||
.input(ZSendDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId } = input;
|
||||
const { documentId, email } = input;
|
||||
|
||||
if (email.message || email.subject) {
|
||||
await upsertDocumentMeta({
|
||||
documentId,
|
||||
subject: email.subject,
|
||||
message: email.message,
|
||||
});
|
||||
}
|
||||
|
||||
return await sendDocument({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@ -21,6 +21,13 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
|
||||
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||
|
||||
export const ZSetTitleForDocumentMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
title: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TSetTitleForDocumentMutationSchema = z.infer<typeof ZSetTitleForDocumentMutationSchema>;
|
||||
|
||||
export const ZSetRecipientsForDocumentMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
@ -58,6 +65,10 @@ export type TSetFieldsForDocumentMutationSchema = z.infer<
|
||||
|
||||
export const ZSendDocumentMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
email: z.object({
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZResendDocumentMutationSchema = z.object({
|
||||
|
||||
@ -1,15 +1,47 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
||||
|
||||
import { procedure, router } from '../trpc';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZAddFieldsMutationSchema,
|
||||
ZRemovedSignedFieldWithTokenMutationSchema,
|
||||
ZSignFieldWithTokenMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const fieldRouter = router({
|
||||
addFields: authenticatedProcedure
|
||||
.input(ZAddFieldsMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, fields } = input;
|
||||
|
||||
return await setFieldsForDocument({
|
||||
documentId,
|
||||
userId: ctx.user.id,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
pageWidth: field.pageWidth,
|
||||
pageHeight: field.pageHeight,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to sign this field. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
signFieldWithToken: procedure
|
||||
.input(ZSignFieldWithTokenMutationSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
|
||||
@ -1,5 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZAddFieldsMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TAddFieldsMutationSchema = z.infer<typeof ZAddFieldsMutationSchema>;
|
||||
|
||||
export const ZSignFieldWithTokenMutationSchema = z.object({
|
||||
token: z.string(),
|
||||
fieldId: z.number(),
|
||||
|
||||
54
packages/trpc/server/recipient-router/router.ts
Normal file
54
packages/trpc/server/recipient-router/router.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { ZAddSignersMutationSchema, ZCompleteDocumentWithTokenMutationSchema } from './schema';
|
||||
|
||||
export const recipientRouter = router({
|
||||
addSigners: authenticatedProcedure
|
||||
.input(ZAddSignersMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, signers } = input;
|
||||
|
||||
return await setRecipientsForDocument({
|
||||
userId: ctx.user.id,
|
||||
documentId,
|
||||
recipients: signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
email: signer.email,
|
||||
name: signer.name,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to sign this field. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
completeDocumentWithToken: procedure
|
||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { token, documentId } = input;
|
||||
|
||||
return await completeDocumentWithToken({
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to sign this field. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
33
packages/trpc/server/recipient-router/schema.ts
Normal file
33
packages/trpc/server/recipient-router/schema.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZAddSignersMutationSchema = z
|
||||
.object({
|
||||
documentId: z.number(),
|
||||
signers: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().email().min(1),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Signers must have unique emails', path: ['signers__root'] },
|
||||
);
|
||||
|
||||
export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema>;
|
||||
|
||||
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
||||
token: z.string(),
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
||||
typeof ZCompleteDocumentWithTokenMutationSchema
|
||||
>;
|
||||
@ -3,18 +3,22 @@ import { authRouter } from './auth-router/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { fieldRouter } from './field-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { recipientRouter } from './recipient-router/router';
|
||||
import { shareLinkRouter } from './share-link-router/router';
|
||||
import { singleplayerRouter } from './singleplayer-router/router';
|
||||
import { router } from './trpc';
|
||||
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
profile: profileRouter,
|
||||
document: documentRouter,
|
||||
field: fieldRouter,
|
||||
recipient: recipientRouter,
|
||||
admin: adminRouter,
|
||||
shareLink: shareLinkRouter,
|
||||
singleplayer: singleplayerRouter,
|
||||
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@ -3,7 +3,7 @@ import { createElement } from 'react';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { renderAsync } from '@documenso/email/render';
|
||||
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||
@ -36,6 +36,7 @@ export const singleplayerRouter = router({
|
||||
});
|
||||
|
||||
const doc = await PDFDocument.load(document);
|
||||
|
||||
const createdAt = new Date();
|
||||
|
||||
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||
@ -149,6 +150,11 @@ export const singleplayerRouter = router({
|
||||
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderAsync(template),
|
||||
renderAsync(template, { plainText: true }),
|
||||
]);
|
||||
|
||||
// Send email to signer.
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
@ -160,8 +166,8 @@ export const singleplayerRouter = router({
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document signed',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
html,
|
||||
text,
|
||||
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
||||
});
|
||||
|
||||
|
||||
105
packages/trpc/server/two-factor-authentication-router/router.ts
Normal file
105
packages/trpc/server/two-factor-authentication-router/router.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
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 { 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;
|
||||
|
||||
return await setupTwoFactorAuthentication({ user, password });
|
||||
}),
|
||||
|
||||
enable: authenticatedProcedure
|
||||
.input(ZEnableTwoFactorAuthenticationMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const user = ctx.user;
|
||||
|
||||
const { code } = input;
|
||||
|
||||
return await enableTwoFactorAuthentication({ user, code });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to enable two-factor authentication. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
disable: authenticatedProcedure
|
||||
.input(ZDisableTwoFactorAuthenticationMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password, backupCode } = input;
|
||||
|
||||
return await disableTwoFactorAuthentication({ user, password, backupCode });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to disable two-factor authentication. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
viewRecoveryCodes: authenticatedProcedure
|
||||
.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,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to view your recovery codes. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
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),
|
||||
});
|
||||
|
||||
export type TEnableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
typeof ZEnableTwoFactorAuthenticationMutationSchema
|
||||
>;
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string().trim(),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
typeof ZDisableTwoFactorAuthenticationMutationSchema
|
||||
>;
|
||||
|
||||
export const ZViewRecoveryCodesMutationSchema = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>;
|
||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -7,6 +7,7 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.2",
|
||||
"next": "14.0.0",
|
||||
"next": "14.0.3",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
|
||||
@ -9,7 +9,8 @@ import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { Field, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, Field, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -29,6 +30,7 @@ export type AddSignersFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
document: DocumentWithData;
|
||||
numberOfSteps: number;
|
||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||
};
|
||||
@ -37,6 +39,7 @@ export const AddSignersFormPartial = ({
|
||||
documentFlow,
|
||||
numberOfSteps,
|
||||
recipients,
|
||||
document,
|
||||
fields: _fields,
|
||||
onSubmit,
|
||||
}: AddSignersFormProps) => {
|
||||
@ -223,6 +226,7 @@ export const AddSignersFormPartial = ({
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
canGoBack={document.status === DocumentStatus.DRAFT}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoBackClick={documentFlow.onBackStep}
|
||||
|
||||
88
packages/ui/primitives/document-flow/add-title.tsx
Normal file
88
packages/ui/primitives/document-flow/add-title.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
|
||||
import type { TAddTitleFormSchema } from './add-title.types';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from './document-flow-root';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
export type AddTitleFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
document: DocumentWithData;
|
||||
numberOfSteps: number;
|
||||
onSubmit: (_data: TAddTitleFormSchema) => void;
|
||||
};
|
||||
|
||||
export const AddTitleFormPartial = ({
|
||||
documentFlow,
|
||||
recipients: _recipients,
|
||||
fields: _fields,
|
||||
document,
|
||||
numberOfSteps,
|
||||
onSubmit,
|
||||
}: AddTitleFormProps) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddTitleFormSchema>({
|
||||
defaultValues: {
|
||||
title: document.title,
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = handleSubmit(onSubmit);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">
|
||||
Title<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="title"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
{...register('title', { required: "Title can't be empty" })}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-2" error={errors.title} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={documentFlow.stepIndex}
|
||||
maxStep={numberOfSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoBackClick={documentFlow.onBackStep}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
packages/ui/primitives/document-flow/add-title.types.ts
Normal file
7
packages/ui/primitives/document-flow/add-title.types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZAddTitleFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TAddTitleFormSchema = z.infer<typeof ZAddTitleFormSchema>;
|
||||
@ -3,6 +3,8 @@ import { z } from 'zod';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZDocumentFlowFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
@ -52,6 +54,6 @@ export interface DocumentFlowStep {
|
||||
title: string;
|
||||
description: string;
|
||||
stepIndex: number;
|
||||
onBackStep?: () => void;
|
||||
onNextStep?: () => void;
|
||||
onBackStep?: () => unknown;
|
||||
onNextStep?: () => unknown;
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './button';
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
@ -25,4 +28,38 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={cn('pr-10', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff aria-hidden className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye aria-hidden className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PasswordInput.displayName = 'Input';
|
||||
|
||||
export { Input, PasswordInput };
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"globalEnv": [
|
||||
"APP_VERSION",
|
||||
"NEXT_PRIVATE_ENCRYPTION_KEY",
|
||||
"NEXTAUTH_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXT_PUBLIC_PROJECT",
|
||||
|
||||
Reference in New Issue
Block a user