Compare commits

..

1 Commits

Author SHA1 Message Date
5f2eac9b5d chore: draft onBlur and unmount
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-29 01:17:32 +05:30
111 changed files with 57945 additions and 3817 deletions

View File

@ -75,7 +75,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
# OPTIONAL: Defines whether to force the use of TLS. # OPTIONAL: Defines whether to force the use of TLS.
NEXT_PRIVATE_SMTP_SECURE= NEXT_PRIVATE_SMTP_SECURE=
# REQUIRED: Defines the sender name to use for the from address. # REQUIRED: Defines the sender name to use for the from address.
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso" NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
# REQUIRED: Defines the email address to use as the from address. # REQUIRED: Defines the email address to use as the from address.
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com" NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
# OPTIONAL: The API key to use for Resend.com # OPTIONAL: The API key to use for Resend.com

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@ -33,9 +33,9 @@ jobs:
- uses: ./.github/actions/cache-build - uses: ./.github/actions/cache-build
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3 uses: github/codeql-action/analyze@v2

View File

@ -33,7 +33,7 @@ jobs:
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: test-results name: test-results

View File

@ -27,7 +27,7 @@ jobs:
- name: Check Assigned User's Issue Count - name: Check Assigned User's Issue Count
id: parse-comment id: parse-comment
uses: actions/github-script@v6 uses: actions/github-script@v5
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

View File

@ -1,25 +0,0 @@
name: Auto Label Assigned Issues
on:
issues:
types: [assigned]
jobs:
label-when-assigned:
runs-on: ubuntu-latest
steps:
- name: Label issue
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issue = context.issue;
// To run only on issues and not on PR
if (github.context.payload.issue.pull_request === undefined) {
const labelResponse = await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: ['status: assigned']
});
}

View File

@ -17,5 +17,5 @@ jobs:
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
labels: ["status: triage"] labels: ["needs triage"]
}) })

View File

@ -2,14 +2,14 @@ name: 'PR Review Reminder'
on: on:
pull_request: pull_request:
types: ['opened', 'ready_for_review'] types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
permissions: permissions:
pull-requests: write pull-requests: write
jobs: jobs:
checkPRs: checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review') if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

View File

@ -12,7 +12,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@v5 - uses: actions/stale@v4
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90 days-before-pr-stale: 90

View File

@ -6,7 +6,7 @@ tasks:
set -a; source .env && set -a; source .env &&
export NEXTAUTH_URL="$(gp url 3000)" && export NEXTAUTH_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" && export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)" export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d command: npm run d
ports: ports:
@ -25,10 +25,20 @@ ports:
- port: 2500 - port: 2500
visibility: private visibility: private
onOpen: ignore onOpen: ignore
- port: 54320 - port: 54320
visibility: private visibility: private
onOpen: ignore onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode: vscode:
extensions: extensions:
- aaron-bond.better-comments - aaron-bond.better-comments
@ -37,5 +47,9 @@ vscode:
- esbenp.prettier-vscode - esbenp.prettier-vscode
- mikestead.dotenv - mikestead.dotenv
- unifiedjs.vscode-mdx - unifiedjs.vscode-mdx
- GitHub.copilot-chat
- GitHub.copilot-labs
- GitHub.copilot
- GitHub.vscode-pull-request-github - GitHub.vscode-pull-request-github
- Prisma.prisma - Prisma.prisma
- VisualStudioExptTeam.vscodeintellicode

View File

@ -18,10 +18,6 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'), path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
); );
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
experimental: { experimental: {
@ -42,7 +38,6 @@ const config = {
env: { env: {
NEXT_PUBLIC_PROJECT: 'marketing', NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`, FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client'; import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
} }
try { try {
const putFileData = await putPdfFile(uploadedFile.file); const putFileData = await putFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({ const documentToken = await createSinglePlayerDocument({
documentData: { documentData: {

View File

@ -18,10 +18,6 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'), path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
); );
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
@ -46,7 +42,6 @@ const config = {
APP_VERSION: version, APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web', NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`, FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {

File diff suppressed because one or more lines are too long

View File

@ -2,8 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Recipient } from '@documenso/prisma/client'; import { type Document, DocumentStatus } from '@documenso/prisma/client';
import { type Document, SigningStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -18,10 +17,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminActionsProps = { export type AdminActionsProps = {
className?: string; className?: string;
document: Document; document: Document;
recipients: Recipient[];
}; };
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => { export const AdminActions = ({ className, document }: AdminActionsProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } = const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
@ -49,9 +47,7 @@ export const AdminActions = ({ className, document, recipients }: AdminActionsPr
<Button <Button
variant="outline" variant="outline"
loading={isResealDocumentLoading} loading={isResealDocumentLoading}
disabled={recipients.some( disabled={document.status !== DocumentStatus.COMPLETED}
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })} onClick={() => resealDocument({ id: document.id })}
> >
Reseal document Reseal document

View File

@ -53,7 +53,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
<h2 className="text-lg font-semibold">Admin Actions</h2> <h2 className="text-lg font-semibold">Admin Actions</h2>
<AdminActions className="mt-2" document={document} recipients={document.Recipient} /> <AdminActions className="mt-2" document={document} />
<hr className="my-4" /> <hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2> <h2 className="text-lg font-semibold">Recipients</h2>

View File

@ -8,7 +8,6 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@ -21,7 +20,6 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { import {
DocumentStatus as DocumentStatusComponent, DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP, FRIENDLY_STATUS_MAP,
@ -86,16 +84,11 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword; documentMeta.password = securePassword;
} }
const [recipients, completedFields] = await Promise.all([ const recipients = await getRecipientsForDocument({
getRecipientsForDocument({ documentId,
documentId, teamId: team?.id,
teamId: team?.id, userId: user.id,
userId: user.id, });
}),
getCompletedFieldsForDocument({
documentId,
}),
]);
const documentWithRecipients = { const documentWithRecipients = {
...document, ...document,
@ -162,13 +155,6 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</CardContent> </CardContent>
</Card> </Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields
fields={completedFields}
documentMeta={document.documentMeta || undefined}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6"> <div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6"> <section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">

View File

@ -224,6 +224,10 @@ export const EditDocumentForm = ({
} }
}; };
const setSubjectFormFields = (subject?: string, message?: string) => {
// Add functionality here
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try { try {
await addFields({ await addFields({
@ -332,7 +336,6 @@ export const EditDocumentForm = ({
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit} onSubmit={onAddSettingsFormSubmit}
/> />
<AddSignersFormPartial <AddSignersFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
@ -360,6 +363,7 @@ export const EditDocumentForm = ({
fields={fields} fields={fields}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
setSubjectFormFields={setSubjectFormFields}
/> />
</Stepper> </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>

View File

@ -36,6 +36,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
const document = await getDocumentWithDetailsById({ const document = await getDocumentWithDetailsById({
id: documentId, id: documentId,
userId: user.id, userId: user.id,
@ -69,11 +74,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentMeta.password = securePassword; documentMeta.password = securePassword;
} }
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return ( return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">

View File

@ -133,11 +133,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
</div> </div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DownloadCertificateButton <DownloadCertificateButton className="mr-2" documentId={document.id} />
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DownloadAuditLogButton documentId={document.id} /> <DownloadAuditLogButton documentId={document.id} />
</div> </div>

View File

@ -2,7 +2,6 @@
import { DownloadIcon } from 'lucide-react'; import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -11,13 +10,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadCertificateButtonProps = { export type DownloadCertificateButtonProps = {
className?: string; className?: string;
documentId: number; documentId: number;
documentStatus: DocumentStatus;
}; };
export const DownloadCertificateButton = ({ export const DownloadCertificateButton = ({
className, className,
documentId, documentId,
documentStatus,
}: DownloadCertificateButtonProps) => { }: DownloadCertificateButtonProps) => {
const { toast } = useToast(); const { toast } = useToast();
@ -72,7 +69,6 @@ export const DownloadCertificateButton = ({
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isLoading} loading={isLoading}
variant="outline" variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()} onClick={() => void onDownloadCertificatesClick()}
> >
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}

View File

@ -3,7 +3,6 @@
import { useTransition } from 'react'; import { useTransition } from 'react';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -63,12 +62,7 @@ export const DocumentsDataTable = ({
{ {
header: 'Created', header: 'Created',
accessorKey: 'createdAt', accessorKey: 'createdAt',
cell: ({ row }) => ( cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
}, },
{ {
header: 'Title', header: 'Title',

View File

@ -10,9 +10,8 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -58,7 +57,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try { try {
setIsLoading(true); setIsLoading(true);
const { type, data } = await putPdfFile(file); const { type, data } = await putFile(file);
const { id: documentDataId } = await createDocumentData({ const { id: documentDataId } = await createDocumentData({
type, type,
@ -84,21 +83,13 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
}); });
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`); router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (err) { } catch (error) {
const error = AppError.parseError(err); console.error(error);
console.error(err); if (error instanceof TRPCClientError) {
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({ toast({
title: 'Error', title: 'Error',
description: err.message, description: error.message,
variant: 'destructive', variant: 'destructive',
}); });
} else { } else {

View File

@ -1,14 +1,10 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -23,135 +19,52 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type EditTemplateFormProps = { export type EditTemplateFormProps = {
className?: string; className?: string;
initialTemplate: TemplateWithDetails; user: User;
isEnterprise: boolean; template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
templateRootPath: string; templateRootPath: string;
}; };
type EditTemplateStep = 'settings' | 'signers' | 'fields'; type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields']; const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
export const EditTemplateForm = ({ export const EditTemplateForm = ({
initialTemplate,
className, className,
isEnterprise, template,
recipients,
fields,
user: _user,
documentData,
templateRootPath, templateRootPath,
}: EditTemplateFormProps) => { }: EditTemplateFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const team = useOptionalCurrentTeam(); const [step, setStep] = useState<EditTemplateStep>('signers');
const [step, setStep] = useState<EditTemplateStep>('settings');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = trpc.useUtils();
const { data: template, refetch: refetchTemplate } =
trpc.template.getTemplateWithDetailsById.useQuery(
{
id: initialTemplate.id,
},
{
initialData: initialTemplate,
...SKIP_QUERY_BATCH_META,
},
);
const { Recipient: recipients, Field: fields, templateDocumentData } = template;
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = { const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: 'General',
description: 'Configure general settings for the template.',
stepIndex: 1,
},
signers: { signers: {
title: 'Add Placeholders', title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.', description: 'Add all relevant placeholders for each recipient.',
stepIndex: 2, stepIndex: 1,
}, },
fields: { fields: {
title: 'Add Fields', title: 'Add Fields',
description: 'Add all relevant fields for each recipient.', description: 'Add all relevant fields for each recipient.',
stepIndex: 3, stepIndex: 2,
}, },
}; };
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({ const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
teamId: team?.id,
data: {
title: data.title,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: data.meta,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the document settings.',
variant: 'destructive',
});
}
};
const onAddTemplatePlaceholderFormSubmit = async ( const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema, data: TAddTemplatePlacholderRecipientsFormSchema,
@ -159,11 +72,9 @@ export const EditTemplateForm = ({
try { try {
await addTemplateSigners({ await addTemplateSigners({
templateId: template.id, templateId: template.id,
teamId: team?.id,
signers: data.signers, signers: data.signers,
}); });
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh(); router.refresh();
setStep('fields'); setStep('fields');
@ -189,9 +100,6 @@ export const EditTemplateForm = ({
duration: 5000, duration: 5000,
}); });
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
router.push(templateRootPath); router.push(templateRootPath);
} catch (err) { } catch (err) {
toast({ toast({
@ -202,15 +110,6 @@ export const EditTemplateForm = ({
} }
}; };
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchTemplate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return ( return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}> <div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card <Card
@ -218,11 +117,7 @@ export const EditTemplateForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer <LazyPDFViewer key={documentData.id} documentData={documentData} />
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent> </CardContent>
</Card> </Card>
@ -240,25 +135,12 @@ export const EditTemplateForm = ({
currentStep={currentDocumentFlow.stepIndex} currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])} setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
> >
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial <AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddTemplateFieldsFormPartial <AddTemplateFieldsFormPartial

View File

@ -5,9 +5,10 @@ import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id'; import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client';
@ -34,7 +35,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
const template = await getTemplateWithDetailsById({ const template = await getTemplateById({
id: templateId, id: templateId,
userId: user.id, userId: user.id,
}).catch(() => null); }).catch(() => null);
@ -43,13 +44,21 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath); redirect(templateRootPath);
} }
const isTemplateEnterprise = await isUserEnterprise({ const { templateDocumentData } = template;
userId: user.id,
teamId: team?.id, const [templateRecipients, templateFields] = await Promise.all([
}); getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return ( return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8"> <div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" /> <ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates Templates
@ -64,10 +73,13 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div> </div>
<EditTemplateForm <EditTemplateForm
className="mt-6" className="mt-8"
initialTemplate={template} template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
templateRootPath={templateRootPath} templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/> />
</div> </div>
); );

View File

@ -1,16 +1,21 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { FilePlus, Loader } from 'lucide-react'; import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -22,8 +27,24 @@ import {
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import {
Form,
FormControl,
FormDescription,
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 { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
type NewTemplateDialogProps = { type NewTemplateDialogProps = {
teamId?: number; teamId?: number;
templateRootPath: string; templateRootPath: string;
@ -35,20 +56,50 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation(); const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false); const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const onFileDrop = async (file: File) => { const onFileDrop = async (file: File) => {
if (isUploadingFile) { try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
if (!form.getValues('name')) {
form.setValue('name', file.name);
}
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const onSubmit = async (values: TCreateTemplateFormSchema) => {
if (!uploadedFile) {
return; return;
} }
setIsUploadingFile(true); const file: File = uploadedFile.file;
try { try {
const { type, data } = await putPdfFile(file); const { type, data } = await putFile(file);
const { id: templateDocumentDataId } = await createDocumentData({ const { id: templateDocumentDataId } = await createDocumentData({
type, type,
data, data,
@ -56,7 +107,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { id } = await createTemplate({ const { id } = await createTemplate({
teamId, teamId,
title: file.name, title: values.name ? values.name : file.name,
templateDocumentDataId, templateDocumentDataId,
}); });
@ -76,16 +127,26 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
description: 'Please try again later.', description: 'Please try again later.',
variant: 'destructive', variant: 'destructive',
}); });
setIsUploadingFile(false);
} }
}; };
const resetForm = () => {
if (form.getValues('name') === uploadedFile?.file.name) {
form.reset();
}
setUploadedFile(null);
};
useEffect(() => {
if (!showNewTemplateDialog) {
form.reset();
setUploadedFile(null);
}
}, [form, showNewTemplateDialog]);
return ( return (
<Dialog <Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
open={showNewTemplateDialog}
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}> <Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" /> <FilePlus className="-ml-1 mr-2 h-4 w-4" />
@ -101,23 +162,80 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="relative"> <Form {...form}>
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" /> <form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Template name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
<span className="text-muted-foreground text-xs">
Leave this empty if you would like to use your document's name for the
template
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isUploadingFile && ( <div className="mt-1.5">
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg"> {uploadedFile ? (
<Loader className="text-muted-foreground h-12 w-12 animate-spin" /> <Card gradient className="h-[40vh]">
</div> <CardContent className="flex h-full flex-col items-center justify-center p-2">
)} <button
</div> onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<DialogFooter> <div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
<DialogClose asChild> <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<Button type="button" variant="secondary" disabled={isUploadingFile}> <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
Close <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</Button> </div>
</DialogClose>
</DialogFooter> <p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
Uploaded Document
</p>
<span className="text-muted-foreground/80 mt-1 text-sm">
{uploadedFile.file.name}
</span>
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
loading={form.formState.isSubmitting}
disabled={!uploadedFile}
type="submit"
>
Create template
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,21 +1,14 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { InfoIcon, Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -26,59 +19,24 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Label } from '@documenso/ui/primitives/label';
import type { Toast } from '@documenso/ui/primitives/use-toast'; import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z const ZAddRecipientsForNewDocumentSchema = z.object({
.object({ recipients: z.array(
sendDocument: z.boolean(), z.object({
recipients: z.array( email: z.string().email(),
z.object({ name: z.string(),
id: z.number(), role: z.nativeEnum(RecipientRole),
email: z.string().email(), }),
name: z.string(), ),
}), });
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -96,33 +54,35 @@ export function UseTemplateDialog({
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const form = useForm<TAddRecipientsForNewDocumentSchema>({ const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: { defaultValues: {
sendDocument: false, recipients:
recipients: recipients.map((recipient) => { recipients.length > 0
const isRecipientEmailPlaceholder = recipient.email.match( ? recipients.map((recipient) => ({
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, nativeId: recipient.id,
); formId: String(recipient.id),
name: recipient.name,
const isRecipientNamePlaceholder = recipient.name.match( email: recipient.email,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, role: recipient.role,
); }))
: [
return { {
id: recipient.id, name: '',
name: !isRecipientNamePlaceholder ? recipient.name : '', email: '',
email: !isRecipientEmailPlaceholder ? recipient.email : '', role: RecipientRole.SIGNER,
}; },
}), ],
}, },
}); });
const { mutateAsync: createDocumentFromTemplate } = const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation(); trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@ -131,7 +91,6 @@ export function UseTemplateDialog({
templateId, templateId,
teamId: team?.id, teamId: team?.id,
recipients: data.recipients, recipients: data.recipients,
sendDocument: data.sendDocument,
}); });
toast({ toast({
@ -142,35 +101,23 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`); router.push(`${documentRootPath}/${id}`);
} catch (err) { } catch (err) {
const error = AppError.parseError(err); toast({
const toastPayload: Toast = {
title: 'Error', title: 'Error',
description: 'An error occurred while creating document from template.', description: 'An error occurred while creating document from template.',
variant: 'destructive', variant: 'destructive',
}; });
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
} }
}; };
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({ const { fields: formRecipients } = useFieldArray({
control: form.control, control,
name: 'recipients', name: 'recipients',
}); });
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return ( return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer"> <Button className="cursor-pointer">
<Plus className="-ml-1 mr-2 h-4 w-4" /> <Plus className="-ml-1 mr-2 h-4 w-4" />
@ -179,110 +126,121 @@ export function UseTemplateDialog({
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Create document from template</DialogTitle> <DialogTitle>Document Recipients</DialogTitle>
<DialogDescription> <DialogDescription>Add the recipients to create the template with.</DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Form {...form}> <Controller
<form onSubmit={form.handleSubmit(onSubmit)}> control={control}
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}> name={`recipients.${index}.email`}
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1"> render={({ field }) => (
{formRecipients.map((recipient, index) => ( <Input
<div className="flex w-full flex-row space-x-4" key={recipient.id}> id={`recipient-${recipient.id}-email`}
<FormField type="email"
control={form.control} className="bg-background mt-2"
name={`recipients.${index}.email`} disabled={isSubmitting}
render={({ field }) => ( {...field}
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email || 'Email'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
)}
<FormField />
control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel>Name</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].name || 'Name'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
</div> </div>
{recipients.length > 0 && ( <div className="flex-1">
<div className="mt-4 flex flex-row items-center"> <Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
<FormField
control={form.control}
name="sendDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label <Controller
className="text-muted-foreground ml-2 flex items-center text-sm" control={control}
htmlFor="sendDocument" name={`recipients.${index}.name`}
> render={({ field }) => (
Send document <Input
<Tooltip> id={`recipient-${recipient.id}-name`}
<TooltipTrigger type="button"> type="text"
<InfoIcon className="mx-1 h-4 w-4" /> className="bg-background mt-2"
</TooltipTrigger> disabled={isSubmitting}
{...field}
/>
)}
/>
</div>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4"> <div className="w-[60px]">
<p> <Controller
The document will be immediately sent to recipients if this is control={control}
checked. name={`recipients.${index}.role`}
</p> render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<p>Otherwise, the document will be created as a draft.</p> <SelectContent className="" align="end">
</TooltipContent> <SelectItem value={RecipientRole.SIGNER}>
</Tooltip> <div className="flex items-center">
</label> <span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
</div> Signer
</FormItem> </div>
)} </SelectItem>
/>
</div>
)}
<DialogFooter> <SelectItem value={RecipientRole.CC}>
<DialogClose asChild> <div className="flex items-center">
<Button type="button" variant="secondary"> <span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Close Receives copy
</Button> </div>
</DialogClose> </SelectItem>
<Button type="submit" loading={form.formState.isSubmitting}> <SelectItem value={RecipientRole.APPROVER}>
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'} <div className="flex items-center">
</Button> <span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
</DialogFooter> Approver
</fieldset> </div>
</form> </SelectItem>
</Form>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))}
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -6,7 +6,6 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
@ -38,7 +37,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient, completedFields] = await Promise.all([ const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
@ -46,7 +45,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }),
]); ]);
if ( if (
@ -127,12 +125,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
signature={user?.email === recipient.email ? user.signature : undefined} signature={user?.email === recipient.email ? user.signature : undefined}
> >
<DocumentAuthProvider document={document} recipient={recipient} user={user}> <DocumentAuthProvider document={document} recipient={recipient} user={user}>
<SigningPageView <SigningPageView recipient={recipient} document={document} fields={fields} />
recipient={recipient}
document={document}
fields={fields}
completedFields={completedFields}
/>
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>
); );

View File

@ -4,14 +4,12 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field'; import { DateField } from './date-field';
@ -25,15 +23,9 @@ export type SigningPageViewProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: Recipient;
fields: Field[]; fields: Field[];
completedFields: CompletedField[];
}; };
export const SigningPageView = ({ export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
document,
recipient,
fields,
completedFields,
}: SigningPageViewProps) => {
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
@ -78,8 +70,6 @@ export const SigningPageView = ({
</div> </div>
</div> </div>
<DocumentReadOnlyFields fields={completedFields} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}> <ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) => {fields.map((field) =>
match(field.type) match(field.type)

View File

@ -1,10 +1,7 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -26,24 +23,7 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const team = await getTeamByUrl({ userId: user.id, teamUrl }); const team = await getTeamByUrl({ userId: user.id, teamUrl });
let tokens: GetTeamTokensResponse | null = null; const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
try {
tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
} catch (err) {
const error = AppError.parseError(err);
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
{match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => error.message)
.otherwise(() => 'Something went wrong.')}
</p>
</div>
);
}
return ( return (
<div> <div>

View File

@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() { export default function SignatureDisclosure() {
return ( return (
<div> <div>
<article className="prose dark:prose-invert"> <article className="prose">
<h1>Electronic Signature Disclosure</h1> <h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2> <h2>Welcome</h2>

View File

@ -1,10 +1,12 @@
'use client'; 'use client';
import { useRef, useState } from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client'; import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
import { PopoverHover } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient'; import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
@ -23,6 +25,11 @@ export const StackAvatarsWithTooltip = ({
position, position,
children, children,
}: StackAvatarsWithTooltipProps) => { }: StackAvatarsWithTooltipProps) => {
const [open, setOpen] = useState(false);
const isControlled = useRef(false);
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
const waitingRecipients = recipients.filter( const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'waiting', (recipient) => getRecipientType(recipient) === 'waiting',
); );
@ -39,74 +46,117 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === 'unsigned', (recipient) => getRecipientType(recipient) === 'unsigned',
); );
const onMouseEnter = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
isMouseOverTimeout.current = setTimeout(() => {
setOpen((o) => (!o ? true : o));
}, 200);
};
const onMouseLeave = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen((o) => (o ? false : o));
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return ( return (
<PopoverHover <Popover open={open} onOpenChange={onOpenChange}>
trigger={children || <StackAvatars recipients={recipients} />} <PopoverTrigger
contentProps={{ className="flex cursor-pointer"
className: 'flex flex-col gap-y-5 py-2', onMouseEnter={onMouseEnter}
side: position, onMouseLeave={onMouseLeave}
}} >
> {children || <StackAvatars recipients={recipients} />}
{completedRecipients.length > 0 && ( </PopoverTrigger>
<div>
<h1 className="text-base font-medium">Completed</h1> <PopoverContent
{completedRecipients.map((recipient: Recipient) => ( side={position}
<div key={recipient.id} className="my-1 flex items-center gap-2"> onMouseEnter={onMouseEnter}
<StackAvatar onMouseLeave={onMouseLeave}
first={true} className="flex flex-col gap-y-5 py-2"
key={recipient.id} >
type={getRecipientType(recipient)} {completedRecipients.length > 0 && (
fallbackText={recipientAbbreviation(recipient)} <div>
/> <h1 className="text-base font-medium">Completed</h1>
<div className=""> {completedRecipients.map((recipient: Recipient) => (
<p className="text-muted-foreground text-sm">{recipient.email}</p> <div key={recipient.id} className="my-1 flex items-center gap-2">
<p className="text-muted-foreground/70 text-xs"> <StackAvatar
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} first={true}
</p> key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div> </div>
</div> ))}
))} </div>
</div> )}
)}
{waitingRecipients.length > 0 && ( {waitingRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Waiting</h1> <h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => ( {waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
documentStatus={documentStatus} documentStatus={documentStatus}
/> />
))} ))}
</div> </div>
)} )}
{openedRecipients.length > 0 && ( {openedRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Opened</h1> <h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
documentStatus={documentStatus} documentStatus={documentStatus}
/> />
))} ))}
</div> </div>
)} )}
{uncompletedRecipients.length > 0 && ( {uncompletedRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Uncompleted</h1> <h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
documentStatus={documentStatus} documentStatus={documentStatus}
/> />
))} ))}
</div> </div>
)} )}
</PopoverHover> </PopoverContent>
</Popover>
); );
}; };

View File

@ -1,112 +0,0 @@
'use client';
import { useState } from 'react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { CompletedField } from '@documenso/lib/types/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentMeta } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: CompletedField[];
documentMeta?: DocumentMeta;
};
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.Recipient.name || field.Recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'flex w-fit flex-col py-2.5 text-sm',
}}
>
<p>
<span className="font-semibold">
{field.Recipient.name
? `${field.Recipient.name} (${field.Recipient.email})`
: field.Recipient.email}{' '}
</span>
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
</p>
<Button
variant="outline"
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
>
Hide field
</Button>
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
{match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.Signature?.signatureImageAsBase64 ? (
<img
src={field.Signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
() => field.customText,
)
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -2,8 +2,6 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth'; import NextAuth from 'next-auth';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -20,29 +18,15 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
error: '/signin', error: '/signin',
}, },
events: { events: {
signIn: async ({ user: { id: userId } }) => { signIn: async ({ user }) => {
const [user] = await Promise.all([ await prisma.userSecurityAuditLog.create({
await prisma.user.findFirstOrThrow({ data: {
where: { userId: user.id,
id: userId, ipAddress,
}, userAgent,
}), type: UserSecurityAuditLogType.SIGN_IN,
await prisma.userSecurityAuditLog.create({ },
data: { });
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
}),
]);
// Create the Stripe customer and attach it to the user if it doesn't exist.
if (user.customerId === null && IS_BILLING_ENABLED()) {
await getStripeCustomerByUser(user).catch((err) => {
console.error(err);
});
}
}, },
signOut: async ({ token }) => { signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id; const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;

View File

@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
export const config = { export const config = {
maxDuration: 120, maxDuration: 60,
api: { api: {
bodyParser: { bodyParser: {
sizeLimit: '50mb', sizeLimit: '50mb',

View File

@ -41,7 +41,7 @@ volumes:
1. Run the following command to start the containers: 1. Run the following command to start the containers:
``` ```
docker-compose --env-file ./.env up -d docker-compose --env-file ./.env -d up
``` ```
This will start the PostgreSQL database and the Documenso application containers. This will start the PostgreSQL database and the Documenso application containers.

View File

@ -58,7 +58,7 @@ services:
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT} - NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP} - NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12} - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?:-/opt/documenso/cert.p12}
ports: ports:
- ${PORT:-3000}:${PORT:-3000} - ${PORT:-3000}:${PORT:-3000}
volumes: volumes:

204
package-lock.json generated
View File

@ -22,7 +22,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.43.0", "playwright": "1.41.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
@ -4702,26 +4702,13 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/browser-chromium": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.43.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.43.1", "version": "1.40.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
"integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", "integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright": "1.43.1" "playwright": "1.40.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -4745,12 +4732,12 @@
} }
}, },
"node_modules/@playwright/test/node_modules/playwright": { "node_modules/@playwright/test/node_modules/playwright": {
"version": "1.43.1", "version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright-core": "1.43.1" "playwright-core": "1.40.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -4763,9 +4750,9 @@
} }
}, },
"node_modules/@playwright/test/node_modules/playwright-core": { "node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.43.1", "version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
"dev": true, "dev": true,
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@ -17536,7 +17523,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -17581,15 +17567,18 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
}, },
"node_modules/pdfjs-dist": { "node_modules/pdfjs-dist": {
"version": "3.11.174", "version": "3.6.172",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.6.172.tgz",
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", "integrity": "sha512-bfOhCg+S9DXh/ImWhWYTOiq3aVMFSCvzGiBzsIJtdMC71kVWDBw7UXr32xh0y56qc5wMVylIeqV3hBaRsu+e+w==",
"dependencies": {
"path2d-polyfill": "^2.0.1",
"web-streams-polyfill": "^3.2.1"
},
"engines": { "engines": {
"node": ">=18" "node": ">=16"
}, },
"optionalDependencies": { "optionalDependencies": {
"canvas": "^2.11.2", "canvas": "^2.11.2"
"path2d-polyfill": "^2.0.1"
} }
}, },
"node_modules/peberminta": { "node_modules/peberminta": {
@ -17671,11 +17660,11 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.43.0", "version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz",
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", "integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==",
"dependencies": { "dependencies": {
"playwright-core": "1.43.0" "playwright-core": "1.41.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -17687,17 +17676,6 @@
"fsevents": "2.3.2" "fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/playwright/node_modules/fsevents": { "node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@ -17711,6 +17689,17 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/playwright/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -19009,6 +18998,42 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
}, },
"node_modules/react-pdf": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.3.3.tgz",
"integrity": "sha512-d7WAxcsjOogJfJ+I+zX/mdip3VjR1yq/yDa4hax4XbQVjbbbup6rqs4c8MGx0MLSnzob17TKp1t4CsNbDZ6GeQ==",
"dependencies": {
"clsx": "^2.0.0",
"make-cancellable-promise": "^1.3.1",
"make-event-props": "^1.6.0",
"merge-refs": "^1.2.1",
"pdfjs-dist": "3.6.172",
"prop-types": "^15.6.2",
"tiny-invariant": "^1.0.0",
"tiny-warning": "^1.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-pdf/node_modules/clsx": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/react-property": { "node_modules/react-property": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz", "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz",
@ -21319,6 +21344,11 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
}, },
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz",
@ -22943,14 +22973,6 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
@ -24946,7 +24968,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.43.0", "playwright": "1.41.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -24954,10 +24976,23 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.43.0", "@playwright/browser-chromium": "1.41.0",
"@types/luxon": "^3.3.1" "@types/luxon": "^3.3.1"
} }
}, },
"packages/lib/node_modules/@playwright/browser-chromium": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz",
"integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.41.0"
},
"engines": {
"node": ">=16"
}
},
"packages/lib/node_modules/nanoid": { "packages/lib/node_modules/nanoid": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
@ -24975,6 +25010,18 @@
"node": "^14 || ^16 || >=18" "node": "^14 || ^16 || >=18"
} }
}, },
"packages/lib/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"packages/prettier-config": { "packages/prettier-config": {
"name": "@documenso/prettier-config", "name": "@documenso/prettier-config",
"version": "0.0.0", "version": "0.0.0",
@ -25355,13 +25402,11 @@
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"next": "14.0.3", "next": "14.0.3",
"pdfjs-dist": "3.11.174", "pdfjs-dist": "3.6.172",
"react": "18.2.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-day-picker": "^8.7.1", "react-day-picker": "^8.7.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",
"react-pdf": "7.7.3", "react-pdf": "7.3.3",
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
@ -25378,43 +25423,6 @@
"typescript": "5.2.2" "typescript": "5.2.2"
} }
}, },
"packages/ui/node_modules/react-pdf": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.7.3.tgz",
"integrity": "sha512-a2VfDl8hiGjugpqezBTUzJHYLNB7IS7a2t7GD52xMI9xHg8LdVaTMsnM9ZlNmKadnStT/tvX5IfV0yLn+JvYmw==",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^1.3.1",
"make-event-props": "^1.6.0",
"merge-refs": "^1.2.1",
"pdfjs-dist": "3.11.174",
"prop-types": "^15.6.2",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"packages/ui/node_modules/react-pdf/node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"packages/ui/node_modules/typescript": { "packages/ui/node_modules/typescript": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",

View File

@ -38,7 +38,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.43.0", "playwright": "1.41.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"

View File

@ -12,8 +12,6 @@ import {
ZDeleteFieldMutationSchema, ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema, ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema, ZDownloadDocumentSuccessfulSchema,
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema, ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema, ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema, ZSuccessfulDocumentResponseSchema,
@ -87,24 +85,6 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema, 404: ZUnsuccessfulResponseSchema,
}, },
summary: 'Create a new document from an existing template', summary: 'Create a new document from an existing template',
deprecated: true,
description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`,
},
generateDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/generate-document',
body: ZGenerateDocumentFromTemplateMutationSchema,
responses: {
200: ZGenerateDocumentFromTemplateMutationResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new document from an existing template',
description:
'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.',
}, },
sendDocument: { sendDocument: {

View File

@ -1,7 +1,6 @@
import { createNextRoute } from '@ts-rest/next'; import { createNextRoute } from '@ts-rest/next';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
@ -20,12 +19,10 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { import {
getPresignGetUrl, getPresignGetUrl,
getPresignPostUrl, getPresignPostUrl,
@ -232,13 +229,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req), requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({ const recipients = await setRecipientsForDocument({
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
@ -289,7 +279,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplateLegacy({ const document = await createDocumentFromTemplate({
templateId, templateId,
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
@ -306,7 +296,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues, formValues: body.formValues,
}); });
const newDocumentData = await putPdfFile({ const newDocumentData = await putFile({
name: fileName, name: fileName,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled), arrayBuffer: async () => Promise.resolve(prefilled),
@ -334,7 +324,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({ await upsertDocumentMeta({
documentId: document.id, documentId: document.id,
userId: user.id, userId: user.id,
...body.meta, subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
requestMetadata: extractNextApiRequestMetadata(args.req), requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
} }
@ -354,85 +347,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
}), }),
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
if (remaining.documents <= 0) {
return {
status: 400,
body: {
message: 'You have reached the maximum number of documents allowed for this month',
},
};
}
const templateId = Number(params.templateId);
let document: CreateDocumentFromTemplateResponse | null = null;
try {
document = await createDocumentFromTemplate({
templateId,
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
override: {
title: body.title,
...body.meta,
},
});
} catch (err) {
return AppError.toRestAPIError(err);
}
if (body.formValues) {
const fileName = document.title.endsWith('.pdf') ? document.title : `${document.title}.pdf`;
const pdf = await getFile(document.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
await updateDocument({
documentId: document.id,
userId: user.id,
teamId: team?.id,
data: {
formValues: body.formValues,
documentData: {
connect: {
id: newDocumentData.id,
},
},
},
});
}
return {
status: 200,
body: {
documentId: document.id,
recipients: document.Recipient.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
})),
},
};
}),
sendDocument: authenticatedMiddleware(async (args, user, team) => { sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params; const { id } = args.params;

View File

@ -1,6 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import { import {
FieldType, FieldType,
ReadStatus, ReadStatus,
@ -142,59 +141,6 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationResponseSchema typeof ZCreateDocumentFromTemplateMutationResponseSchema
>; >;
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
recipients: z
.array(
z.object({
id: z.number(),
name: z.string().optional(),
email: z.string().email().min(1),
}),
)
.refine(
(schema) => {
const emails = schema.map((signer) => signer.email.toLowerCase());
const ids = schema.map((signer) => signer.id);
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
},
{ message: 'Recipient IDs and emails must be unique' },
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
})
.partial()
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
});
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
typeof ZGenerateDocumentFromTemplateMutationSchema
>;
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZGenerateDocumentFromTemplateMutationResponseSchema
>;
export const ZCreateRecipientMutationSchema = z.object({ export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: z.string().email().min(1), email: z.string().email().min(1),

View File

@ -41,8 +41,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth. // Set EE action auth.
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step. // Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -52,7 +52,11 @@ test.describe('[EE_ONLY]', () => {
await page.getByRole('button', { name: 'Go Back' }).click(); await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await unseedUser(user.id); await unseedUser(user.id);
}); });
@ -85,8 +89,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth. // Set EE action auth.
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step. // Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -164,8 +168,11 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click(); await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Title')).toHaveValue('New Title'); // Todo: Verify that the values are correct once we fix the issue where going back
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await unseedUser(user.id); await unseedUser(user.id);
}); });

View File

@ -48,7 +48,7 @@ test.describe('[EE_ONLY]', () => {
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings. // Display advanced settings.
await page.getByLabel('Show advanced settings').check(); await page.getByLabel('Show advanced settings').click();
// Navigate to the next step and back. // Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -62,6 +62,7 @@ test.describe('[EE_ONLY]', () => {
}); });
}); });
// Note: Not complete yet due to issue with back button.
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
const document = await seedBlankDocument(user); const document = await seedBlankDocument(user);
@ -92,5 +93,26 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click(); await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// // Add advanced settings for a single recipient.
// await page.getByLabel('Show advanced settings').click();
// await page.getByRole('combobox').first().click();
// await page.getByLabel('Require account').click();
// // Navigate to the next step and back.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// await page.getByRole('button', { name: 'Go Back' }).click();
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id); await unseedUser(user.id);
}); });

View File

@ -1,167 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[TEMPLATE_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/templates/${template.id}`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set title.
await page.getByLabel('Title').fill('New Title');
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});

View File

@ -1,106 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is unchecked, since no advanced settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// Add advanced settings for a single recipient.
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});
});
test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});

View File

@ -1,285 +0,0 @@
import { expect, test } from '@playwright/test';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
/**
* 1. Create a template with all settings filled out
* 2. Create a document from the template
* 3. Ensure all values are correct
*
* Note: There is a direct copy paste of this test below for teams.
*
* If you update this test please update that test as well.
*/
test('[TEMPLATE]: should create a document from a template', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.Recipient[0];
const recipientTwo = document.Recipient[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});
/**
* This is a direct copy paste of the above test but for teams.
*/
test('[TEMPLATE]: should create a team document from a team template', async ({ page }) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: owner.id,
priceId: enterprisePriceId,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}`,
});
// Set template title.
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
await page.getByLabel('Message (Optional)').fill('MESSAGE');
// Set advanced options.
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
await page.getByLabel('DD/MM/YYYY').click();
await page.locator('.time-zone-field').click();
await page.getByRole('option', { name: 'Etc/UTC' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
expect(document.teamId).toEqual(team.id);
const documentAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
expect(document.documentMeta?.subject).toEqual('SUBJECT');
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
const recipientOne = document.Recipient[0];
const recipientTwo = document.Recipient[1];
const recipientOneAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientOne.authOptions,
});
const recipientTwoAuth = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});

View File

@ -189,14 +189,7 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template. // Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/); await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents'); await page.waitForURL('/documents');
@ -207,14 +200,7 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template. // Use team template.
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/); await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`); await page.waitForURL(`/t/${team.url}/documents`);

Binary file not shown.

View File

@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id'; import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentStatus, DocumentStatus,
@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url), new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer()); ).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putPdfFile({ const { id: documentDataId } = await putFile({
name: 'Documenso Supporter Pledge.pdf', name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer), arrayBuffer: async () => Promise.resolve(documentBuffer),

View File

@ -21,7 +21,6 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account. * Does not take any person or group properties into account.
*/ */
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = { export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true', app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false, app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag. app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.

View File

@ -1,6 +1,6 @@
import { APP_BASE_URL } from './app'; import { APP_BASE_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 12; export const DEFAULT_STANDARD_FONT_SIZE = 15;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50; export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8; export const MIN_STANDARD_FONT_SIZE = 8;

View File

@ -1,2 +0,0 @@
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;

View File

@ -1,5 +1,4 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
@ -150,24 +149,4 @@ export class AppError extends Error {
return null; return null;
} }
} }
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.otherwise(() => 500 as const);
return {
status,
body: {
message: status !== 500 ? error.message : 'Something went wrong',
},
};
}
} }

View File

@ -39,7 +39,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.43.0", "playwright": "1.41.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -48,6 +48,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@playwright/browser-chromium": "1.43.0" "@playwright/browser-chromium": "1.41.0"
} }
} }

View File

@ -1,12 +0,0 @@
import { z } from 'zod';
import { URL_REGEX } from '../constants/url-regex';
/**
* Note this allows empty strings.
*/
export const ZUrlSchema = z
.string()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
});

View File

@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id }); await sendPendingEmail({ documentId, recipientId: recipient.id });
} }
const haveAllRecipientsSigned = await prisma.document.findFirst({ const documents = await prisma.document.updateMany({
where: { where: {
id: document.id, id: document.id,
Recipient: { Recipient: {
@ -146,9 +146,13 @@ export const completeDocumentWithToken = async ({
}, },
}, },
}, },
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
}); });
if (haveAllRecipientsSigned) { if (documents.count > 0) {
await sealDocument({ documentId: document.id, requestMetadata }); await sealDocument({ documentId: document.id, requestMetadata });
} }

View File

@ -75,20 +75,18 @@ export const deleteDocument = async ({
} }
// Continue to hide the document from the user if they are a recipient. // Continue to hide the document from the user if they are a recipient.
// Dirty way of doing this but it's faster than refetching the document.
if (userRecipient?.documentDeletedAt === null) { if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient await prisma.recipient.update({
.update({ where: {
where: { documentId_email: {
id: userRecipient.id, documentId: document.id,
email: user.email,
}, },
data: { },
documentDeletedAt: new Date().toISOString(), data: {
}, documentDeletedAt: new Date().toISOString(),
}) },
.catch(() => { });
// Do nothing.
});
} }
// Return partial document for API v1 response. // Return partial document for API v1 response.

View File

@ -110,7 +110,7 @@ export const resendDocument = async ({
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '', selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate, customEmailTemplate,
), ),
role: recipient.role, role: recipient.role,
@ -135,7 +135,7 @@ export const resendDocument = async ({
address: FROM_ADDRESS, address: FROM_ADDRESS,
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject, : emailSubject,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),

View File

@ -14,10 +14,9 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file'; import { putFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -41,11 +40,6 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
}, },
include: { include: {
documentData: true, documentData: true,
@ -59,6 +53,10 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`); throw new Error(`Document ${document.id} has no document data`);
} }
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
documentId: document.id, documentId: document.id,
@ -94,24 +92,22 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy // !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData); const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId }) const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
.then(async (doc) => PDFDocument.load(doc)) PDFDocument.load(doc),
.catch(() => null); );
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature // Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(doc); normalizeSignatureAppearances(doc);
flattenForm(doc); doc.getForm().flatten();
flattenAnnotations(doc); flattenAnnotations(doc);
if (certificate) { const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => { certificatePages.forEach((page) => {
doc.addPage(page); doc.addPage(page);
}); });
}
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);
@ -123,7 +119,7 @@ export const sealDocument = async ({
const { name, ext } = path.parse(document.title); const { name, ext } = path.parse(document.title);
const { data: newData } = await putPdfFile({ const { data: newData } = await putFile({
name: `${name}_signed${ext}`, name: `${name}_signed${ext}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
@ -142,16 +138,6 @@ export const sealDocument = async ({
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({ await tx.documentData.update({
where: { where: {
id: documentData.id, id: documentData.id,

View File

@ -4,11 +4,8 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -21,6 +18,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles'; } from '../../constants/recipient-roles';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -102,7 +100,7 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>, formValues: document.formValues as Record<string, string | number | boolean>,
}); });
const newDocumentData = await putPdfFile({ const newDocumentData = await putFile({
name: document.title, name: document.title,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled), arrayBuffer: async () => Promise.resolve(prefilled),
@ -151,7 +149,7 @@ export const sendDocument = async ({
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '', selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate, customEmailTemplate,
), ),
role: recipient.role, role: recipient.role,
@ -213,31 +211,6 @@ export const sendDocument = async ({
}), }),
); );
const allRecipientsHaveNoActionToTake = document.Recipient.every(
(recipient) => recipient.role === RecipientRole.CC,
);
if (allRecipientsHaveNoActionToTake) {
const updatedDocument = await updateDocument({
documentId,
userId,
teamId,
data: { status: DocumentStatus.COMPLETED },
});
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => { const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) { if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({

View File

@ -1,29 +0,0 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForDocumentOptions = {
documentId: number;
};
export const getCompletedFieldsForDocument = async ({
documentId,
}: GetCompletedFieldsForDocumentOptions) => {
return await prisma.field.findMany({
where: {
documentId,
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};

View File

@ -1,33 +0,0 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForTokenOptions = {
token: string;
};
export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => {
return await prisma.field.findMany({
where: {
Document: {
Recipient: {
some: {
token,
},
},
},
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};

View File

@ -1,19 +1,22 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client'; import type { FieldType } from '@documenso/prisma/client';
export type Field = {
id?: number | null;
type: FieldType;
signerEmail: string;
signerId?: number;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
};
export type SetFieldsForTemplateOptions = { export type SetFieldsForTemplateOptions = {
userId: number; userId: number;
templateId: number; templateId: number;
fields: { fields: Field[];
id?: number | null;
type: FieldType;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
}[];
}; };
export const setFieldsForTemplate = async ({ export const setFieldsForTemplate = async ({
@ -55,7 +58,11 @@ export const setFieldsForTemplate = async ({
}); });
const removedFields = existingFields.filter( const removedFields = existingFields.filter(
(existingField) => !fields.find((field) => field.id === existingField.id), (existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
); );
const linkedFields = fields.map((field) => { const linkedFields = fields.map((field) => {
@ -120,13 +127,5 @@ export const setFieldsForTemplate = async ({
}); });
} }
// Filter out fields that have been removed or have been updated. return persistedFields;
const filteredFields = existingFields.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated;
});
return [...filteredFields, ...persistedFields];
}; };

View File

@ -18,9 +18,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
let browser: Browser; let browser: Browser;
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) { if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version. browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
} else { } else {
browser = await chromium.launch(); browser = await chromium.launch();
} }
@ -35,7 +33,6 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, { await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle', waitUntil: 'networkidle',
timeout: 10_000,
}); });
const result = await page.pdf({ const result = await page.pdf({

View File

@ -1,112 +0,0 @@
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
import { PDFCheckBox, PDFRadioGroup, PDFRef } from 'pdf-lib';
import {
PDFDict,
type PDFDocument,
PDFName,
drawObject,
popGraphicsState,
pushGraphicsState,
rotateInPlace,
translate,
} from 'pdf-lib';
export const flattenForm = (document: PDFDocument) => {
const form = document.getForm();
form.updateFieldAppearances();
for (const field of form.getFields()) {
for (const widget of field.acroField.getWidgets()) {
flattenWidget(document, field, widget);
}
try {
form.removeField(field);
} catch (error) {
console.error(error);
}
}
};
const getPageForWidget = (document: PDFDocument, widget: PDFWidgetAnnotation) => {
const pageRef = widget.P();
let page = document.getPages().find((page) => page.ref === pageRef);
if (!page) {
const widgetRef = document.context.getObjectRef(widget.dict);
if (!widgetRef) {
return null;
}
page = document.findPageForAnnotationRef(widgetRef);
if (!page) {
return null;
}
}
return page;
};
const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const normalAppearance = widget.getNormalAppearance();
let normalAppearanceRef: PDFRef | null = null;
if (normalAppearance instanceof PDFRef) {
normalAppearanceRef = normalAppearance;
}
if (
normalAppearance instanceof PDFDict &&
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
) {
const value = field.acroField.getValue();
const ref = normalAppearance.get(value) ?? normalAppearance.get(PDFName.of('Off'));
if (ref instanceof PDFRef) {
normalAppearanceRef = ref;
}
}
return normalAppearanceRef;
} catch (error) {
console.error(error);
return null;
}
};
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
try {
const page = getPageForWidget(document, widget);
if (!page) {
return;
}
const appearanceRef = getAppearanceRefForWidget(field, widget);
if (!appearanceRef) {
return;
}
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
const rectangle = widget.getRectangle();
const operators = [
pushGraphicsState(),
translate(rectangle.x, rectangle.y),
...rotateInPlace({ ...rectangle, rotation: 0 }),
drawObject(xObjectKey),
popGraphicsState(),
].filter((op) => !!op);
page.pushOperators(...operators);
} catch (error) {
console.error(error);
}
};

View File

@ -1,6 +1,6 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 // https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument, StandardFonts } from 'pdf-lib';
import { import {
DEFAULT_HANDWRITING_FONT_SIZE, DEFAULT_HANDWRITING_FONT_SIZE,
@ -17,10 +17,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
res.arrayBuffer(), res.arrayBuffer(),
); );
const fontNoto = await fetch(process.env.FONT_NOTO_SANS_URI).then(async (res) =>
res.arrayBuffer(),
);
const isSignatureField = isSignatureFieldType(field.type); const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit); pdf.registerFontkit(fontkit);
@ -45,7 +41,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const fieldX = pageWidth * (Number(field.positionX) / 100); const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100); const fieldY = pageHeight * (Number(field.positionY) / 100);
const font = await pdf.embedFont(isSignatureField ? fontCaveat : fontNoto); const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) { if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat); await pdf.embedFont(fontCaveat);

View File

@ -1,4 +1,3 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client'; import { TeamMemberRole } from '@documenso/prisma/client';
@ -7,8 +6,6 @@ export type GetUserTokensOptions = {
teamId: number; teamId: number;
}; };
export type GetTeamTokensResponse = Awaited<ReturnType<typeof getTeamTokens>>;
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => { export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({ const teamMember = await prisma.teamMember.findFirst({
where: { where: {
@ -18,10 +15,7 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
}); });
if (teamMember?.role !== TeamMemberRole.ADMIN) { if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new AppError( throw new Error('You do not have permission to view tokens for this team');
AppErrorCode.UNAUTHORIZED,
'You do not have the required permissions to view this page.',
);
} }
return await prisma.apiToken.findMany({ return await prisma.apiToken.findMany({

View File

@ -1,32 +1,21 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client'; import type { RecipientRole } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '../../types/document-auth';
import { nanoid } from '../../universal/id'; import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
export type SetRecipientsForTemplateOptions = { export type SetRecipientsForTemplateOptions = {
userId: number; userId: number;
teamId?: number;
templateId: number; templateId: number;
recipients: { recipients: {
id?: number; id?: number;
email: string; email: string;
name: string; name: string;
role: RecipientRole; role: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
}[]; }[];
}; };
export const setRecipientsForTemplate = async ({ export const setRecipientsForTemplate = async ({
userId, userId,
teamId,
templateId, templateId,
recipients, recipients,
}: SetRecipientsForTemplateOptions) => { }: SetRecipientsForTemplateOptions) => {
@ -54,23 +43,6 @@ export const setRecipientsForTemplate = async ({
throw new Error('Template not found'); throw new Error('Template not found');
} }
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const normalizedRecipients = recipients.map((recipient) => ({ const normalizedRecipients = recipients.map((recipient) => ({
...recipient, ...recipient,
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
@ -102,59 +74,31 @@ export const setRecipientsForTemplate = async ({
}; };
}); });
const persistedRecipients = await prisma.$transaction(async (tx) => { const persistedRecipients = await prisma.$transaction(
return await Promise.all( // Disabling as wrapping promises here causes type issues
linkedRecipients.map(async (recipient) => { // eslint-disable-next-line @typescript-eslint/promise-function-async
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions); linkedRecipients.map((recipient) =>
prisma.recipient.upsert({
if (recipient.actionAuth !== undefined) { where: {
authOptions = createRecipientAuthOptions({ id: recipient._persisted?.id ?? -1,
accessAuth: authOptions.accessAuth, templateId,
actionAuth: recipient.actionAuth, },
}); update: {
} name: recipient.name,
email: recipient.email,
const upsertedRecipient = await tx.recipient.upsert({ role: recipient.role,
where: { templateId,
id: recipient._persisted?.id ?? -1, },
templateId, create: {
}, name: recipient.name,
update: { email: recipient.email,
name: recipient.name, role: recipient.role,
email: recipient.email, token: nanoid(),
role: recipient.role, templateId,
templateId, },
authOptions,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
authOptions,
},
});
const recipientId = upsertedRecipient.id;
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
recipient._persisted &&
recipient._persisted.role !== recipient.role &&
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId,
},
});
}
return upsertedRecipient;
}), }),
); ),
}); );
if (removedRecipients.length > 0) { if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({ await prisma.recipient.deleteMany({
@ -166,17 +110,5 @@ export const setRecipientsForTemplate = async ({
}); });
} }
// Filter out recipients that have been removed or have been updated. return persistedRecipients;
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
const isUpdated = persistedRecipients.find(
(persistedRecipient) => persistedRecipient.id === recipient.id,
);
return !isRemoved && !isUpdated;
});
return [...filteredRecipients, ...persistedRecipients];
}; };

View File

@ -1,144 +0,0 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateLegacyOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
};
/**
* Legacy server function for /api/v1
*/
export const createDocumentFromTemplateLegacy = async ({
templateId,
userId,
teamId,
recipients,
}: CreateDocumentFromTemplateLegacyOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
templateDocumentData: true,
},
});
if (!template) {
throw new Error('Template not found.');
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
data: template.templateDocumentData.data,
initialData: template.templateDocumentData.initialData,
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
if (!documentRecipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
documentId: document.id,
recipientId: documentRecipient.id,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}),
);
}
return document;
};

View File

@ -1,52 +1,16 @@
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Field } from '@documenso/prisma/client'; import type { RecipientRole } from '@documenso/prisma/client';
import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
export type CreateDocumentFromTemplateResponse = Awaited<
ReturnType<typeof createDocumentFromTemplate>
>;
export type CreateDocumentFromTemplateOptions = { export type CreateDocumentFromTemplateOptions = {
templateId: number; templateId: number;
userId: number; userId: number;
teamId?: number; teamId?: number;
recipients: { recipients?: {
id: number;
name?: string; name?: string;
email: string; email: string;
role?: RecipientRole;
}[]; }[];
/**
* Values that will override the predefined values in the template.
*/
override?: {
title?: string;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
};
requestMetadata?: RequestMetadata;
}; };
export const createDocumentFromTemplate = async ({ export const createDocumentFromTemplate = async ({
@ -54,15 +18,7 @@ export const createDocumentFromTemplate = async ({
userId, userId,
teamId, teamId,
recipients, recipients,
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => { }: CreateDocumentFromTemplateOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: { where: {
id: templateId, id: templateId,
@ -83,51 +39,16 @@ export const createDocumentFromTemplate = async ({
}), }),
}, },
include: { include: {
Recipient: { Recipient: true,
include: { Field: true,
Field: true,
},
},
templateDocumentData: true, templateDocumentData: true,
templateMeta: true,
}, },
}); });
if (!template) { if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); throw new Error('Template not found.');
} }
// Check that all the passed in recipient IDs can be associated with a template recipient.
recipients.forEach((recipient) => {
const foundRecipient = template.Recipient.find(
(templateRecipient) => templateRecipient.id === recipient.id,
);
if (!foundRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
`Recipient with ID ${recipient.id} not found in the template.`,
);
}
});
const { documentAuthOption: templateAuthOptions } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name,
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
role: templateRecipient.role,
authOptions: templateRecipient.authOptions,
};
});
const documentData = await prisma.documentData.create({ const documentData = await prisma.documentData.create({
data: { data: {
type: template.templateDocumentData.type, type: template.templateDocumentData.type,
@ -136,104 +57,81 @@ export const createDocumentFromTemplate = async ({
}, },
}); });
return await prisma.$transaction(async (tx) => { const document = await prisma.document.create({
const document = await tx.document.create({ data: {
data: {
userId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,
authOptions: createDocumentAuthOptions({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
password: override?.password || template.templateMeta?.password,
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
},
},
Recipient: {
createMany: {
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
authOptions: createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
token: nanoid(),
};
}),
},
},
},
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.Recipient.find((recipient) => recipient.email === email);
if (!recipient) {
throw new Error('Recipient not found.');
}
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => ({
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
data: {
title: document.title,
},
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId, userId,
teamId, teamId: template.teamId,
}); title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
return document; include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
}); });
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
documentId: document.id,
recipientId: documentRecipient?.id || null,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}),
);
}
return document;
}; };

View File

@ -81,10 +81,6 @@ export const duplicateTemplate = async ({
(doc) => doc.email === recipient?.email, (doc) => doc.email === recipient?.email,
); );
if (!duplicatedTemplateRecipient) {
throw new Error('Recipient not found.');
}
return { return {
type: field.type, type: field.type,
page: field.page, page: field.page,
@ -95,7 +91,7 @@ export const duplicateTemplate = async ({
customText: field.customText, customText: field.customText,
inserted: field.inserted, inserted: field.inserted,
templateId: duplicatedTemplate.id, templateId: duplicatedTemplate.id,
recipientId: duplicatedTemplateRecipient.id, recipientId: duplicatedTemplateRecipient?.id || null,
}; };
}), }),
}); });

View File

@ -1,38 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
export type GetTemplateWithDetailsByIdOptions = {
id: number;
userId: number;
};
export const getTemplateWithDetailsById = async ({
id,
userId,
}: GetTemplateWithDetailsByIdOptions): Promise<TemplateWithDetails> => {
return await prisma.template.findFirstOrThrow({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
templateDocumentData: true,
templateMeta: true,
Recipient: true,
Field: true,
},
});
};

View File

@ -1,139 +0,0 @@
'use server';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import type { TemplateMeta } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateTemplateSettingsOptions = {
userId: number;
teamId?: number;
templateId: number;
data: {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata?: RequestMetadata;
};
export const updateTemplateSettings = async ({
userId,
teamId,
templateId,
meta,
data,
}: UpdateTemplateSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
const template = await prisma.template.findFirstOrThrow({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
templateMeta: true,
},
});
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const { templateMeta } = template;
const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null);
const isMessageSame = (templateMeta?.message || null) === (meta?.message || null);
const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null);
const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null);
const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null);
const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null);
// Early return to avoid unnecessary updates.
if (
template.title === data.title &&
data.globalAccessAuth === documentAuthOption.globalAccessAuth &&
data.globalActionAuth === documentAuthOption.globalActionAuth &&
isDateSame &&
isMessageSame &&
isPasswordSame &&
isSubjectSame &&
isRedirectUrlSame &&
isTimezoneSame
) {
return template;
}
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
return await prisma.template.update({
where: {
id: templateId,
},
data: {
title: data.title,
authOptions,
templateMeta: {
upsert: {
where: {
templateId,
},
create: {
...meta,
},
update: {
...meta,
},
},
},
},
});
};

View File

@ -4,7 +4,6 @@ export const getWebhooksByUserId = async (userId: number) => {
return await prisma.webhook.findMany({ return await prisma.webhook.findMany({
where: { where: {
userId, userId,
teamId: null,
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',

View File

@ -1,3 +0,0 @@
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];

View File

@ -17,7 +17,6 @@ export const getFlag = async (
options?: GetFlagOptions, options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => { ): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {}; const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) { if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true; return LOCAL_FEATURE_FLAGS[flag] ?? true;
@ -26,7 +25,7 @@ export const getFlag = async (
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`); const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag); url.searchParams.set('flag', flag);
return await fetch(url, { const response = await fetch(url, {
headers: { headers: {
...requestHeaders, ...requestHeaders,
}, },
@ -36,10 +35,9 @@ export const getFlag = async (
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res)) .then((res) => ZFeatureFlagValueSchema.parse(res))
.catch((err) => { .catch(() => false);
console.error(err);
return LOCAL_FEATURE_FLAGS[flag] ?? false; return response;
});
}; };
/** /**
@ -52,7 +50,6 @@ export const getAllFlags = async (
options?: GetFlagOptions, options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => { ): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {}; const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) { if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS; return LOCAL_FEATURE_FLAGS;
@ -70,10 +67,7 @@ export const getAllFlags = async (
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch((err) => { .catch(() => LOCAL_FEATURE_FLAGS);
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
}; };
/** /**
@ -95,10 +89,7 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch((err) => { .catch(() => LOCAL_FEATURE_FLAGS);
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
}; };
interface GetFlagOptions { interface GetFlagOptions {

View File

@ -1,12 +1,9 @@
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { DocumentDataType } from '@documenso/prisma/client'; import { DocumentDataType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data'; import { createDocumentData } from '../../server-only/document-data/create-document-data';
type File = { type File = {
@ -15,38 +12,14 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>; arrayBuffer: () => Promise<ArrayBuffer>;
}; };
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFile = async (file: File) => {
const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
() => false,
);
// This will prevent uploading encrypted PDFs or anything that can't be opened.
if (!isEncryptedDocumentsAllowed) {
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
}
const { type, data } = await putFile(file);
return await createDocumentData({ type, data });
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => { export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file)) .with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file)); .otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
}; };
const putFileInDatabase = async (file: File) => { const putFileInDatabase = async (file: File) => {

View File

@ -1,11 +0,0 @@
/*
Warnings:
- Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column.
*/
-- Drop all Fields where the recipientId is null
DELETE FROM "Field" WHERE "recipientId" IS NULL;
-- AlterTable
ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL;

View File

@ -1,22 +0,0 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "authOptions" JSONB;
-- CreateTable
CREATE TABLE "TemplateMeta" (
"id" TEXT NOT NULL,
"subject" TEXT,
"message" TEXT,
"timezone" TEXT DEFAULT 'Etc/UTC',
"password" TEXT,
"dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a',
"templateId" INTEGER NOT NULL,
"redirectUrl" TEXT,
CONSTRAINT "TemplateMeta_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TemplateMeta_templateId_key" ON "TemplateMeta"("templateId");
-- AddForeignKey
ALTER TABLE "TemplateMeta" ADD CONSTRAINT "TemplateMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -387,7 +387,7 @@ model Field {
secondaryId String @unique @default(cuid()) secondaryId String @unique @default(cuid())
documentId Int? documentId Int?
templateId Int? templateId Int?
recipientId Int recipientId Int?
type FieldType type FieldType
page Int page Int
positionX Decimal @default(0) positionX Decimal @default(0)
@ -398,7 +398,7 @@ model Field {
inserted Boolean inserted Boolean
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature? Signature Signature?
@@index([documentId]) @@index([documentId])
@ -539,29 +539,15 @@ enum TemplateType {
PRIVATE PRIVATE
} }
model TemplateMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
}
model Template { model Template {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
type TemplateType @default(PRIVATE) type TemplateType @default(PRIVATE)
title String title String
userId Int userId Int
teamId Int? teamId Int?
authOptions Json?
templateMeta TemplateMeta?
templateDocumentDataId String templateDocumentDataId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)

View File

@ -2,7 +2,6 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { prisma } from '..'; import { prisma } from '..';
import type { Prisma, User } from '../client';
import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client'; import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client';
const examplePdf = fs const examplePdf = fs
@ -15,32 +14,6 @@ type SeedTemplateOptions = {
teamId?: number; teamId?: number;
}; };
type CreateTemplateOptions = {
key?: string | number;
createTemplateOptions?: Partial<Prisma.TemplateUncheckedCreateInput>;
};
export const seedBlankTemplate = async (owner: User, options: CreateTemplateOptions = {}) => {
const { key, createTemplateOptions = {} } = options;
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
return await prisma.template.create({
data: {
title: `[TEST] Template ${key}`,
templateDocumentDataId: documentData.id,
userId: owner.id,
...createTemplateOptions,
},
});
};
export const seedTemplate = async (options: SeedTemplateOptions) => { export const seedTemplate = async (options: SeedTemplateOptions) => {
const { title = 'Untitled', userId, teamId } = options; const { title = 'Untitled', userId, teamId } = options;

View File

@ -1,19 +0,0 @@
import type {
DocumentData,
Field,
Recipient,
Template,
TemplateMeta,
} from '@documenso/prisma/client';
export type TemplateWithData = Template & {
templateDocumentData?: DocumentData | null;
templateMeta?: TemplateMeta | null;
};
export type TemplateWithDetails = Template & {
templateDocumentData: DocumentData;
templateMeta: TemplateMeta | null;
Recipient: Recipient[];
Field: Field[];
};

View File

@ -1,7 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient'; import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
import { updateUser } from '@documenso/lib/server-only/admin/update-user'; import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
@ -11,7 +10,6 @@ import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upse
import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { adminProcedure, router } from '../trpc'; import { adminProcedure, router } from '../trpc';
import { import {
@ -102,13 +100,9 @@ export const adminRouter = router({
const { id } = input; const { id } = input;
try { try {
const document = await getEntireDocument({ id }); return await sealDocument({ documentId: id, isResealing: true });
const isResealing = document.status === DocumentStatus.COMPLETED;
return await sealDocument({ documentId: id, isResealing });
} catch (err) { } catch (err) {
console.error('resealDocument error', err); console.log('resealDocument error', err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@ -129,7 +123,7 @@ export const adminRouter = router({
return await deleteUser({ id }); return await deleteUser({ id });
} catch (err) { } catch (err) {
console.error(err); console.log(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@ -150,7 +144,7 @@ export const adminRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); console.log(err);
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',

View File

@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError } from '@documenso/lib/errors/app-error';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
@ -21,7 +20,6 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda
import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -223,6 +221,10 @@ export const documentRouter = router({
} }
}), }),
getDocumentMetaById: authenticatedProcedure
.input(ZSetSettingsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {}),
setTitleForDocument: authenticatedProcedure setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema) .input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@ -415,10 +417,6 @@ export const documentRouter = router({
teamId, teamId,
}); });
if (document.status !== DocumentStatus.COMPLETED) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}
const encrypted = encryptSecondaryData({ const encrypted = encryptSecondaryData({
data: document.id.toString(), data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),

View File

@ -53,7 +53,7 @@ export const fieldRouter = router({
const { templateId, fields } = input; const { templateId, fields } = input;
try { try {
return await setFieldsForTemplate({ await setFieldsForTemplate({
userId: ctx.user.id, userId: ctx.user.id,
templateId, templateId,
fields: fields.map((field) => ({ fields: fields.map((field) => ({

View File

@ -90,7 +90,7 @@ export const profileRouter = router({
try { try {
const { url } = input; const { url } = input;
if (IS_BILLING_ENABLED() && url.length < 6) { if (IS_BILLING_ENABLED() && url.length <= 6) {
const subscriptions = await getSubscriptionsByUserId({ const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id, userId: ctx.user.id,
}).then((subscriptions) => }).then((subscriptions) =>

View File

@ -46,18 +46,16 @@ export const recipientRouter = router({
.input(ZAddTemplateSignersMutationSchema) .input(ZAddTemplateSignersMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { templateId, signers, teamId } = input; const { templateId, signers } = input;
return await setRecipientsForTemplate({ return await setRecipientsForTemplate({
userId: ctx.user.id, userId: ctx.user.id,
teamId,
templateId, templateId,
recipients: signers.map((signer) => ({ recipients: signers.map((signer) => ({
id: signer.nativeId, id: signer.nativeId,
email: signer.email, email: signer.email,
name: signer.name, name: signer.name,
role: signer.role, role: signer.role,
actionAuth: signer.actionAuth,
})), })),
}); });
} catch (err) { } catch (err) {

View File

@ -34,7 +34,6 @@ export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema
export const ZAddTemplateSignersMutationSchema = z export const ZAddTemplateSignersMutationSchema = z
.object({ .object({
teamId: z.number().optional(),
templateId: z.number(), templateId: z.number(),
signers: z.array( signers: z.array(
z.object({ z.object({
@ -42,7 +41,6 @@ export const ZAddTemplateSignersMutationSchema = z
email: z.string().email().min(1), email: z.string().email().min(1),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}), }),
), ),
}) })

View File

@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id'; import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentStatus, DocumentStatus,
@ -86,7 +86,7 @@ export const singleplayerRouter = router({
}, },
}); });
const { id: documentDataId } = await putPdfFile({ const { id: documentDataId } = await putFile({
name: `${documentName}.pdf`, name: `${documentName}.pdf`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer), arrayBuffer: async () => Promise.resolve(signedPdfBuffer),

View File

@ -1,16 +1,10 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template'; import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Document } from '@documenso/prisma/client';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, router } from '../trpc';
import { import {
@ -18,8 +12,6 @@ import {
ZCreateTemplateMutationSchema, ZCreateTemplateMutationSchema,
ZDeleteTemplateMutationSchema, ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema, ZDuplicateTemplateMutationSchema,
ZGetTemplateWithDetailsByIdQuerySchema,
ZUpdateTemplateSettingsMutationSchema,
} from './schema'; } from './schema';
export const templateRouter = router({ export const templateRouter = router({
@ -57,34 +49,19 @@ export const templateRouter = router({
throw new Error('You have reached your document limit.'); throw new Error('You have reached your document limit.');
} }
const requestMetadata = extractNextApiRequestMetadata(ctx.req); return await createDocumentFromTemplate({
let document: Document = await createDocumentFromTemplate({
templateId, templateId,
teamId, teamId,
userId: ctx.user.id, userId: ctx.user.id,
recipients: input.recipients, recipients: input.recipients,
requestMetadata,
}); });
if (input.sendDocument) {
document = await sendDocument({
documentId: document.id,
userId: ctx.user.id,
teamId,
requestMetadata,
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
}
return document;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
throw AppError.parseErrorToTRPCError(err); throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
} }
}), }),
@ -127,52 +104,4 @@ export const templateRouter = router({
}); });
} }
}), }),
getTemplateWithDetailsById: authenticatedProcedure
.input(ZGetTemplateWithDetailsByIdQuerySchema)
.query(async ({ input, ctx }) => {
try {
return await getTemplateWithDetailsById({
id: input.id,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this template. Please try again later.',
});
}
}),
// Todo: Add API
updateTemplateSettings: authenticatedProcedure
.input(ZUpdateTemplateSettingsMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { templateId, teamId, data, meta } = input;
const userId = ctx.user.id;
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
return await updateTemplateSettings({
userId,
teamId,
templateId,
data,
meta,
requestMetadata,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to update the settings for this template. Please try again later.',
});
}
}),
}); });

View File

@ -1,10 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex'; import { RecipientRole } from '@documenso/prisma/client';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
export const ZCreateTemplateMutationSchema = z.object({ export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(), title: z.string().min(1).trim(),
@ -18,16 +14,12 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
recipients: z recipients: z
.array( .array(
z.object({ z.object({
id: z.number(),
email: z.string().email(), email: z.string().email(),
name: z.string().optional(), name: z.string(),
role: z.nativeEnum(RecipientRole),
}), }),
) )
.refine((recipients) => { .optional(),
const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
sendDocument: z.boolean().optional(),
}); });
export const ZDuplicateTemplateMutationSchema = z.object({ export const ZDuplicateTemplateMutationSchema = z.object({
@ -39,38 +31,10 @@ export const ZDeleteTemplateMutationSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
}); });
export const ZUpdateTemplateSettingsMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().min(1).optional(),
data: z.object({
title: z.string().min(1).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
}),
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({
id: z.number().min(1),
});
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>; export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TCreateDocumentFromTemplateMutationSchema = z.infer< export type TCreateDocumentFromTemplateMutationSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationSchema typeof ZCreateDocumentFromTemplateMutationSchema
>; >;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>; export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>; export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
typeof ZGetTemplateWithDetailsByIdQuerySchema
>;

View File

@ -73,7 +73,6 @@ declare namespace NodeJS {
DEPLOYMENT_TARGET?: 'webapp' | 'marketing'; DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
FONT_CAVEAT_URI: string; FONT_CAVEAT_URI: string;
FONT_NOTO_SANS_URI: string;
POSTGRES_URL?: string; POSTGRES_URL?: string;
DATABASE_URL?: string; DATABASE_URL?: string;

View File

@ -1,66 +0,0 @@
'use client';
import React, { forwardRef } from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
),
);
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
export const DocumentGlobalAuthAccessTooltip = () => (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Document access</strong>
</h2>
<p>The authentication required for recipients to view the document.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Require account</strong> - The recipient must be signed in to view the document
</li>
<li>
<strong>None</strong> - The document can be accessed directly by the URL sent to the
recipient
</li>
</ul>
</TooltipContent>
</Tooltip>
);

View File

@ -1,80 +0,0 @@
'use client';
import React, { forwardRef } from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue ref={ref} data-testid="documentActionSelectValue" placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
),
);
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
export const DocumentGlobalAuthActionTooltip = () => (
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<h2>
<strong>Global recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign the signature field.</p>
<p>
This can be overriden by setting the authentication requirements directly on each recipient
in the next step.
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
{/* <li>
<strong>Require account</strong> - The recipient must be signed in
</li> */}
<li>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled via
their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
);

View File

@ -1,34 +0,0 @@
'use client';
import React from 'react';
export const DocumentSendEmailMessageHelper = () => {
return (
<div>
<p className="text-muted-foreground text-sm">
You can use the following variables in your message:
</p>
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.name}'}
</code>{' '}
- The signer's name
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.email}'}
</code>{' '}
- The signer's email
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{document.name}'}
</code>{' '}
- The document's name
</li>
</ul>
</div>
);
};

View File

@ -19,7 +19,6 @@ export type FieldContainerPortalProps = {
field: Field; field: Field;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
cardClassName?: string;
}; };
export function FieldContainerPortal({ export function FieldContainerPortal({
@ -45,7 +44,7 @@ export function FieldContainerPortal({
); );
} }
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) { export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
@ -79,7 +78,6 @@ export function FieldRootContainer({ field, children, cardClassName }: FieldCont
{ {
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating, 'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
}, },
cardClassName,
)} )}
ref={ref} ref={ref}
data-inserted={field.inserted ? 'true' : 'false'} data-inserted={field.inserted ? 'true' : 'false'}

View File

@ -1,80 +0,0 @@
'use client';
import React from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { RecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientActionAuthSelectProps = SelectProps;
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</li>
{/* <li>
<strong>Require account</strong> - The recipient must be
signed in
</li> */}
<li>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
</li>
<li>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
via their settings
</li>
<li>
<strong>None</strong> - No authentication required
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@ -1,97 +0,0 @@
'use client';
import React, { forwardRef } from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { RecipientRole } from '@documenso/prisma/client';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientRoleSelectProps = SelectProps;
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, SelectProps>((props, ref) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[props.value as RecipientRole]}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Needs to sign
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to sign the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to approve the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>The recipient is required to view the document for it to be completed.</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is not required to take any action and receives a copy of the document
after it is completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectContent>
</Select>
));
RecipientRoleSelect.displayName = 'RecipientRoleSelect';

Some files were not shown because too many files have changed in this diff Show More