mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Compare commits
95 Commits
chore/subj
...
v1.5.6-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 94cf412f29 | |||
| 6650a1d72e | |||
| 82848e3d2e | |||
| 22b8c2044b | |||
| ef5d267e96 | |||
| 518ddea081 | |||
| 805758f716 | |||
| 04ebb26a0b | |||
| 9cb80aa0bc | |||
| 0985206088 | |||
| aadb22cdbf | |||
| 3e304b37b2 | |||
| 1f3df51371 | |||
| 6e2363d48c | |||
| 64bec5f29c | |||
| 311328471e | |||
| d58a88196a | |||
| f1c6fc6fb7 | |||
| babdbccbd3 | |||
| 3e634fd975 | |||
| 4c0b772fc9 | |||
| 24b228acf7 | |||
| e072e270f8 | |||
| d37edc4351 | |||
| a877c64aca | |||
| 2f86bb523b | |||
| 788933b75d | |||
| bbcbc56e70 | |||
| 8f9c07aa8e | |||
| cc4efddabf | |||
| 968b116012 | |||
| 2ba0f48c61 | |||
| 5d5d0210fa | |||
| e50ccca766 | |||
| d7a3c40050 | |||
| dc11676d28 | |||
| e8d4fe46e5 | |||
| 64e3e2c64b | |||
| e4620efa4a | |||
| 84bbcea7bb | |||
| 15dee5ef35 | |||
| 28d6f6e2e8 | |||
| 78dc57a6eb | |||
| d3528f74f0 | |||
| dbd452be97 | |||
| 5109bb17d6 | |||
| 6974a76ed4 | |||
| 5efb0894e6 | |||
| cfec366c1a | |||
| 8622e68853 | |||
| 0e16a86e74 | |||
| dca4b8eaec | |||
| 97d334a1da | |||
| 345e42537a | |||
| 80c03fcf3f | |||
| c98c1b9467 | |||
| 8a24ca2065 | |||
| 06dd8219a5 | |||
| 74b9bc786b | |||
| 364c499927 | |||
| b0ce06f6fe | |||
| 20edee7f1a | |||
| 9bc5818d19 | |||
| 481d739c37 | |||
| 88dedc9829 | |||
| 4080806606 | |||
| e949fb14ae | |||
| e1573465f6 | |||
| 0062359977 | |||
| 03727bfad2 | |||
| c4a680caf7 | |||
| 1e33bc2aa3 | |||
| 4de122f814 | |||
| e4cf9c8251 | |||
| 41ed6c9ad7 | |||
| d7959950e2 | |||
| bb43547a45 | |||
| 3fb69422e8 | |||
| 4d5365bddc | |||
| 0eee570781 | |||
| afaeba9739 | |||
| f6e6dac46c | |||
| a97ffa97a4 | |||
| 6526377f1b | |||
| 870de02efa | |||
| a58a117056 | |||
| 918e9ddc0b | |||
| 94eee8b913 | |||
| 345c4b8b14 | |||
| 897f0dabde | |||
| d5867ae8de | |||
| 5391dd91b0 | |||
| 4855882ae6 | |||
| c08768a330 | |||
| 37e9db6626 |
@ -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="No Reply @ Documenso"
|
NEXT_PRIVATE_SMTP_FROM_NAME="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
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
|||||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@ -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@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|||||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@ -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@v3
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
|
|||||||
2
.github/workflows/issue-assignee-check.yml
vendored
2
.github/workflows/issue-assignee-check.yml
vendored
@ -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@v5
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
25
.github/workflows/issue-labeler.yml
vendored
Normal file
25
.github/workflows/issue-labeler.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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']
|
||||||
|
});
|
||||||
|
}
|
||||||
2
.github/workflows/issue-opened.yml
vendored
2
.github/workflows/issue-opened.yml
vendored
@ -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: ["needs triage"]
|
labels: ["status: triage"]
|
||||||
})
|
})
|
||||||
|
|||||||
4
.github/workflows/pr-review-reminder.yml
vendored
4
.github/workflows/pr-review-reminder.yml
vendored
@ -2,14 +2,14 @@ name: 'PR Review Reminder'
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
|
types: ['opened', 'ready_for_review']
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checkPRs:
|
checkPRs:
|
||||||
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
|
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v4
|
- uses: actions/stale@v5
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
days-before-pr-stale: 90
|
days-before-pr-stale: 90
|
||||||
|
|||||||
20
.gitpod.yml
20
.gitpod.yml
@ -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,20 +25,10 @@ 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
|
||||||
@ -47,9 +37,5 @@ 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
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@ 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: {
|
||||||
@ -38,6 +42,7 @@ 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': {
|
||||||
|
|||||||
2
apps/marketing/public/pdf.worker.min.js
vendored
2
apps/marketing/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } 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 putFile(uploadedFile.file);
|
const putFileData = await putPdfFile(uploadedFile.file);
|
||||||
|
|
||||||
const documentToken = await createSinglePlayerDocument({
|
const documentToken = await createSinglePlayerDocument({
|
||||||
documentData: {
|
documentData: {
|
||||||
@ -248,6 +248,7 @@ export const SinglePlayerClient = () => {
|
|||||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onFieldsSubmit}
|
onSubmit={onFieldsSubmit}
|
||||||
|
canGoBack={true}
|
||||||
isDocumentPdfLoaded={true}
|
isDocumentPdfLoaded={true}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -18,6 +18,10 @@ 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,
|
||||||
@ -42,6 +46,7 @@ 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': {
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
"cookie-es": "^1.0.0",
|
"cookie-es": "^1.0.0",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
|
|||||||
56591
apps/web/public/pdf.worker.min.js
vendored
56591
apps/web/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { type Document, DocumentStatus } from '@documenso/prisma/client';
|
import type { Recipient } 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';
|
||||||
@ -17,9 +18,10 @@ 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 }: AdminActionsProps) => {
|
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
||||||
@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
loading={isResealDocumentLoading}
|
loading={isResealDocumentLoading}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={recipients.some(
|
||||||
|
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||||
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
Reseal document
|
Reseal document
|
||||||
|
|||||||
@ -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} />
|
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
<h2 className="text-lg font-semibold">Recipients</h2>
|
<h2 className="text-lg font-semibold">Recipients</h2>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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';
|
||||||
@ -20,6 +21,7 @@ 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,
|
||||||
@ -84,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = await getRecipientsForDocument({
|
const [recipients, completedFields] = await Promise.all([
|
||||||
documentId,
|
getRecipientsForDocument({
|
||||||
teamId: team?.id,
|
documentId,
|
||||||
userId: user.id,
|
teamId: team?.id,
|
||||||
});
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getCompletedFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const documentWithRecipients = {
|
const documentWithRecipients = {
|
||||||
...document,
|
...document,
|
||||||
@ -155,6 +162,13 @@ 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">
|
||||||
|
|||||||
@ -224,10 +224,6 @@ 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({
|
||||||
@ -336,6 +332,7 @@ 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}
|
||||||
@ -363,7 +360,6 @@ export const EditDocumentForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
setSubjectFormFields={setSubjectFormFields}
|
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
|
|||||||
@ -36,11 +36,6 @@ 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,
|
||||||
@ -74,6 +69,11 @@ 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">
|
||||||
|
|||||||
@ -133,7 +133,11 @@ 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 className="mr-2" documentId={document.id} />
|
<DownloadCertificateButton
|
||||||
|
className="mr-2"
|
||||||
|
documentId={document.id}
|
||||||
|
documentStatus={document.status}
|
||||||
|
/>
|
||||||
|
|
||||||
<DownloadAuditLogButton documentId={document.id} />
|
<DownloadAuditLogButton documentId={document.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -10,11 +11,13 @@ 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();
|
||||||
|
|
||||||
@ -69,6 +72,7 @@ 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" />}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
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';
|
||||||
@ -62,7 +63,12 @@ export const DocumentsDataTable = ({
|
|||||||
{
|
{
|
||||||
header: 'Created',
|
header: 'Created',
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
cell: ({ row }) => (
|
||||||
|
<LocaleDate
|
||||||
|
date={row.original.createdAt}
|
||||||
|
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
|
|||||||
@ -10,8 +10,9 @@ 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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } 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';
|
||||||
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const { type, data } = await putFile(file);
|
const { type, data } = await putPdfFile(file);
|
||||||
|
|
||||||
const { id: documentDataId } = await createDocumentData({
|
const { id: documentDataId } = await createDocumentData({
|
||||||
type,
|
type,
|
||||||
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error(error);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error instanceof TRPCClientError) {
|
console.error(err);
|
||||||
|
|
||||||
|
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: error.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
import {
|
||||||
|
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';
|
||||||
@ -19,52 +23,135 @@ 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;
|
||||||
user: User;
|
initialTemplate: TemplateWithDetails;
|
||||||
template: Template;
|
isEnterprise: boolean;
|
||||||
recipients: Recipient[];
|
|
||||||
fields: Field[];
|
|
||||||
documentData: DocumentData;
|
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditTemplateStep = 'signers' | 'fields';
|
type EditTemplateStep = 'settings' | 'signers' | 'fields';
|
||||||
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
|
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
||||||
|
|
||||||
export const EditTemplateForm = ({
|
export const EditTemplateForm = ({
|
||||||
|
initialTemplate,
|
||||||
className,
|
className,
|
||||||
template,
|
isEnterprise,
|
||||||
recipients,
|
|
||||||
fields,
|
|
||||||
user: _user,
|
|
||||||
documentData,
|
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
}: EditTemplateFormProps) => {
|
}: EditTemplateFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditTemplateStep>('signers');
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
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: 1,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
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: 2,
|
stepIndex: 3,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
|
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({
|
||||||
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 { 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,
|
||||||
@ -72,9 +159,11 @@ 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');
|
||||||
@ -100,6 +189,9 @@ 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({
|
||||||
@ -110,6 +202,15 @@ 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
|
||||||
@ -117,7 +218,11 @@ export const EditTemplateForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
<LazyPDFViewer
|
||||||
|
key={templateDocumentData.id}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -135,12 +240,25 @@ 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
|
||||||
|
|||||||
@ -5,10 +5,9 @@ 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 { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||||
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';
|
||||||
|
|
||||||
@ -35,7 +34,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const template = await getTemplateById({
|
const template = await getTemplateWithDetailsById({
|
||||||
id: templateId,
|
id: templateId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
@ -44,21 +43,13 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
redirect(templateRootPath);
|
redirect(templateRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { templateDocumentData } = template;
|
const isTemplateEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
const [templateRecipients, templateFields] = await Promise.all([
|
teamId: team?.id,
|
||||||
getRecipientsForTemplate({
|
});
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
getFieldsForTemplate({
|
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 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
|
||||||
@ -73,13 +64,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditTemplateForm
|
<EditTemplateForm
|
||||||
className="mt-8"
|
className="mt-6"
|
||||||
template={template}
|
initialTemplate={template}
|
||||||
user={user}
|
|
||||||
recipients={templateRecipients}
|
|
||||||
fields={templateFields}
|
|
||||||
documentData={templateDocumentData}
|
|
||||||
templateRootPath={templateRootPath}
|
templateRootPath={templateRootPath}
|
||||||
|
isEnterprise={isTemplateEnterprise}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { FilePlus, Loader } from 'lucide-react';
|
||||||
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 { 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 { 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,
|
||||||
@ -27,24 +22,8 @@ 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;
|
||||||
@ -56,50 +35,20 @@ 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 [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
if (isUploadingFile) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file: File = uploadedFile.file;
|
setIsUploadingFile(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { type, data } = await putFile(file);
|
const { type, data } = await putPdfFile(file);
|
||||||
|
|
||||||
const { id: templateDocumentDataId } = await createDocumentData({
|
const { id: templateDocumentDataId } = await createDocumentData({
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
@ -107,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
teamId,
|
teamId,
|
||||||
title: values.name ? values.name : file.name,
|
title: file.name,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -127,26 +76,16 @@ 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 open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
|
<Dialog
|
||||||
|
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" />
|
||||||
@ -162,80 +101,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<div className="relative">
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-1.5">
|
{isUploadingFile && (
|
||||||
{uploadedFile ? (
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||||
<Card gradient className="h-[40vh]">
|
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||||
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={() => resetForm()}
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<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">
|
<DialogFooter>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
<DialogClose asChild>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
Close
|
||||||
</div>
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
</DialogFooter>
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,14 +1,21 @@
|
|||||||
|
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 { Plus } from 'lucide-react';
|
import { InfoIcon, Plus } from 'lucide-react';
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
import { 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,
|
||||||
@ -19,24 +26,59 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import {
|
||||||
|
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 { Label } from '@documenso/ui/primitives/label';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
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.object({
|
const ZAddRecipientsForNewDocumentSchema = z
|
||||||
recipients: z.array(
|
.object({
|
||||||
z.object({
|
sendDocument: z.boolean(),
|
||||||
email: z.string().email(),
|
recipients: z.array(
|
||||||
name: z.string(),
|
z.object({
|
||||||
role: z.nativeEnum(RecipientRole),
|
id: z.number(),
|
||||||
}),
|
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>;
|
||||||
|
|
||||||
@ -54,35 +96,33 @@ 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 {
|
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TAddRecipientsForNewDocumentSchema>({
|
|
||||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
recipients:
|
sendDocument: false,
|
||||||
recipients.length > 0
|
recipients: recipients.map((recipient) => {
|
||||||
? recipients.map((recipient) => ({
|
const isRecipientEmailPlaceholder = recipient.email.match(
|
||||||
nativeId: recipient.id,
|
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||||
formId: String(recipient.id),
|
);
|
||||||
name: recipient.name,
|
|
||||||
email: recipient.email,
|
const isRecipientNamePlaceholder = recipient.name.match(
|
||||||
role: recipient.role,
|
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||||
}))
|
);
|
||||||
: [
|
|
||||||
{
|
return {
|
||||||
name: '',
|
id: recipient.id,
|
||||||
email: '',
|
name: !isRecipientNamePlaceholder ? recipient.name : '',
|
||||||
role: RecipientRole.SIGNER,
|
email: !isRecipientEmailPlaceholder ? recipient.email : '',
|
||||||
},
|
};
|
||||||
],
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
@ -91,6 +131,7 @@ export function UseTemplateDialog({
|
|||||||
templateId,
|
templateId,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
|
sendDocument: data.sendDocument,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -101,23 +142,35 @@ export function UseTemplateDialog({
|
|||||||
|
|
||||||
router.push(`${documentRootPath}/${id}`);
|
router.push(`${documentRootPath}/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
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,
|
control: form.control,
|
||||||
name: 'recipients',
|
name: 'recipients',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<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" />
|
||||||
@ -126,121 +179,110 @@ export function UseTemplateDialog({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Document Recipients</DialogTitle>
|
<DialogTitle>Create document from template</DialogTitle>
|
||||||
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
|
<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>
|
|
||||||
|
|
||||||
<Controller
|
<Form {...form}>
|
||||||
control={control}
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
name={`recipients.${index}.email`}
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
render={({ field }) => (
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
<Input
|
{formRecipients.map((recipient, index) => (
|
||||||
id={`recipient-${recipient.id}-email`}
|
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||||
type="email"
|
<FormField
|
||||||
className="bg-background mt-2"
|
control={form.control}
|
||||||
disabled={isSubmitting}
|
name={`recipients.${index}.email`}
|
||||||
{...field}
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel required>Email</FormLabel>}
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={recipients[index].email || 'Email'} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
<FormField
|
||||||
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
|
control={form.control}
|
||||||
|
name={`recipients.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel>Name</FormLabel>}
|
||||||
|
|
||||||
<Controller
|
<FormControl>
|
||||||
control={control}
|
<Input {...field} placeholder={recipients[index].name || 'Name'} />
|
||||||
name={`recipients.${index}.name`}
|
</FormControl>
|
||||||
render={({ field }) => (
|
<FormMessage />
|
||||||
<Input
|
</FormItem>
|
||||||
id={`recipient-${recipient.id}-name`}
|
)}
|
||||||
type="text"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
{...field}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
/>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[60px]">
|
{recipients.length > 0 && (
|
||||||
<Controller
|
<div className="mt-4 flex flex-row items-center">
|
||||||
control={control}
|
<FormField
|
||||||
name={`recipients.${index}.role`}
|
control={form.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
name="sendDocument"
|
||||||
<Select value={value} onValueChange={(x) => onChange(x)}>
|
render={({ field }) => (
|
||||||
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
<SelectContent className="" align="end">
|
<label
|
||||||
<SelectItem value={RecipientRole.SIGNER}>
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
<div className="flex items-center">
|
htmlFor="sendDocument"
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
>
|
||||||
Signer
|
Send document
|
||||||
</div>
|
<Tooltip>
|
||||||
</SelectItem>
|
<TooltipTrigger type="button">
|
||||||
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.CC}>
|
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||||
<div className="flex items-center">
|
<p>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
The document will be immediately sent to recipients if this is
|
||||||
Receives copy
|
checked.
|
||||||
</div>
|
</p>
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.APPROVER}>
|
<p>Otherwise, the document will be created as a draft.</p>
|
||||||
<div className="flex items-center">
|
</TooltipContent>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
</Tooltip>
|
||||||
Approver
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.VIEWER}>
|
<DialogFooter>
|
||||||
<div className="flex items-center">
|
<DialogClose asChild>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
<Button type="button" variant="secondary">
|
||||||
Viewer
|
Close
|
||||||
</div>
|
</Button>
|
||||||
</SelectItem>
|
</DialogClose>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full">
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
|
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</fieldset>
|
||||||
))}
|
</form>
|
||||||
</div>
|
</Form>
|
||||||
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
|
|
||||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
|
||||||
@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
|
|||||||
<FormLabel required>2FA token</FormLabel>
|
<FormLabel required>2FA token</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Token" />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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';
|
||||||
@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@ -45,6 +46,7 @@ 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 (
|
||||||
@ -125,7 +127,12 @@ 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 recipient={recipient} document={document} fields={fields} />
|
<SigningPageView
|
||||||
|
recipient={recipient}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
completedFields={completedFields}
|
||||||
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,12 +4,14 @@ 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';
|
||||||
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
|
|||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
completedFields: CompletedField[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
|
export const SigningPageView = ({
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
completedFields,
|
||||||
|
}: SigningPageViewProps) => {
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: 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)
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
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';
|
||||||
@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
|
|||||||
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
|
let tokens: GetTeamTokensResponse | null = null;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@ -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">
|
<article className="prose dark:prose-invert">
|
||||||
<h1>Electronic Signature Disclosure</h1>
|
<h1>Electronic Signature Disclosure</h1>
|
||||||
|
|
||||||
<h2>Welcome</h2>
|
<h2>Welcome</h2>
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
'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 { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
import { PopoverHover } 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';
|
||||||
@ -25,11 +23,6 @@ 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',
|
||||||
);
|
);
|
||||||
@ -46,117 +39,74 @@ 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 (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<PopoverHover
|
||||||
<PopoverTrigger
|
trigger={children || <StackAvatars recipients={recipients} />}
|
||||||
className="flex cursor-pointer"
|
contentProps={{
|
||||||
onMouseEnter={onMouseEnter}
|
className: 'flex flex-col gap-y-5 py-2',
|
||||||
onMouseLeave={onMouseLeave}
|
side: position,
|
||||||
>
|
}}
|
||||||
{children || <StackAvatars recipients={recipients} />}
|
>
|
||||||
</PopoverTrigger>
|
{completedRecipients.length > 0 && (
|
||||||
|
<div>
|
||||||
<PopoverContent
|
<h1 className="text-base font-medium">Completed</h1>
|
||||||
side={position}
|
{completedRecipients.map((recipient: Recipient) => (
|
||||||
onMouseEnter={onMouseEnter}
|
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||||
onMouseLeave={onMouseLeave}
|
<StackAvatar
|
||||||
className="flex flex-col gap-y-5 py-2"
|
first={true}
|
||||||
>
|
key={recipient.id}
|
||||||
{completedRecipients.length > 0 && (
|
type={getRecipientType(recipient)}
|
||||||
<div>
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
<h1 className="text-base font-medium">Completed</h1>
|
/>
|
||||||
{completedRecipients.map((recipient: Recipient) => (
|
<div className="">
|
||||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||||
<StackAvatar
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
first={true}
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
key={recipient.id}
|
</p>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</PopoverContent>
|
</PopoverHover>
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
@ -25,6 +26,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
const MotionLink = motion(Link);
|
||||||
|
|
||||||
export type MenuSwitcherProps = {
|
export type MenuSwitcherProps = {
|
||||||
user: User;
|
user: User;
|
||||||
teams: GetTeamsResponse;
|
teams: GetTeamsResponse;
|
||||||
@ -170,18 +173,43 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
<DropdownMenuItem asChild key={team.id}>
|
||||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
<MotionLink
|
||||||
|
initial="initial"
|
||||||
|
animate="initial"
|
||||||
|
whileHover="animate"
|
||||||
|
href={formatRedirectUrlOnSwitch(team.url)}
|
||||||
|
>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
avatarFallback={formatAvatarFallback(team.name)}
|
||||||
primaryText={team.name}
|
primaryText={team.name}
|
||||||
secondaryText={formatSecondaryAvatarText(team)}
|
secondaryText={
|
||||||
|
<div className="relative">
|
||||||
|
<motion.span
|
||||||
|
className="overflow-hidden"
|
||||||
|
variants={{
|
||||||
|
initial: { opacity: 1, translateY: 0 },
|
||||||
|
animate: { opacity: 0, translateY: '100%' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatSecondaryAvatarText(team)}
|
||||||
|
</motion.span>
|
||||||
|
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-0"
|
||||||
|
variants={{
|
||||||
|
initial: { opacity: 0, translateY: '100%' },
|
||||||
|
animate: { opacity: 1, translateY: 0 },
|
||||||
|
}}
|
||||||
|
>{`/t/${team.url}`}</motion.span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
rightSideComponent={
|
rightSideComponent={
|
||||||
isPathTeamUrl(team.url) && (
|
isPathTeamUrl(team.url) && (
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</MotionLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -28,7 +28,7 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZDisable2FAForm = z.object({
|
export const ZDisable2FAForm = z.object({
|
||||||
@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Token" />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="text" value={field.value ?? ''} />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Token" />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||||
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||||
@ -372,9 +373,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
name="totpCode"
|
name="totpCode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Authentication Token</FormLabel>
|
<FormLabel>Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -2,6 +2,8 @@ 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';
|
||||||
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
error: '/signin',
|
error: '/signin',
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
signIn: async ({ user }) => {
|
signIn: async ({ user: { id: userId } }) => {
|
||||||
await prisma.userSecurityAuditLog.create({
|
const [user] = await Promise.all([
|
||||||
data: {
|
await prisma.user.findFirstOrThrow({
|
||||||
userId: user.id,
|
where: {
|
||||||
ipAddress,
|
id: userId,
|
||||||
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;
|
||||||
|
|||||||
@ -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: 60,
|
maxDuration: 120,
|
||||||
api: {
|
api: {
|
||||||
bodyParser: {
|
bodyParser: {
|
||||||
sizeLimit: '50mb',
|
sizeLimit: '50mb',
|
||||||
|
|||||||
@ -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 -d up
|
docker-compose --env-file ./.env up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start the PostgreSQL database and the Documenso application containers.
|
This will start the PostgreSQL database and the Documenso application containers.
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
214
package-lock.json
generated
214
package-lock.json
generated
@ -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.41.0",
|
"playwright": "1.43.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"
|
||||||
@ -109,6 +109,7 @@
|
|||||||
"cookie-es": "^1.0.0",
|
"cookie-es": "^1.0.0",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
@ -4702,13 +4703,26 @@
|
|||||||
"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.40.0",
|
"version": "1.43.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
|
||||||
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==",
|
"integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.40.0"
|
"playwright": "1.43.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@ -4732,12 +4746,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test/node_modules/playwright": {
|
"node_modules/@playwright/test/node_modules/playwright": {
|
||||||
"version": "1.40.0",
|
"version": "1.43.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
|
||||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.40.0"
|
"playwright-core": "1.43.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@ -4750,9 +4764,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||||
"version": "1.40.0",
|
"version": "1.43.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
|
||||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@ -13754,6 +13768,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
||||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
|
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/input-otp": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
|
||||||
@ -17523,6 +17546,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@ -17567,18 +17591,15 @@
|
|||||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||||
},
|
},
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "3.6.172",
|
"version": "3.11.174",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.6.172.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
"integrity": "sha512-bfOhCg+S9DXh/ImWhWYTOiq3aVMFSCvzGiBzsIJtdMC71kVWDBw7UXr32xh0y56qc5wMVylIeqV3hBaRsu+e+w==",
|
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
|
||||||
"dependencies": {
|
|
||||||
"path2d-polyfill": "^2.0.1",
|
|
||||||
"web-streams-polyfill": "^3.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"canvas": "^2.11.2"
|
"canvas": "^2.11.2",
|
||||||
|
"path2d-polyfill": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/peberminta": {
|
"node_modules/peberminta": {
|
||||||
@ -17660,11 +17681,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.41.0",
|
"version": "1.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
|
||||||
"integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==",
|
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.41.0"
|
"playwright-core": "1.43.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@ -17676,6 +17697,17 @@
|
|||||||
"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",
|
||||||
@ -17689,17 +17721,6 @@
|
|||||||
"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",
|
||||||
@ -18998,42 +19019,6 @@
|
|||||||
"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",
|
||||||
@ -21344,11 +21329,6 @@
|
|||||||
"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",
|
||||||
@ -22973,6 +22953,14 @@
|
|||||||
"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",
|
||||||
@ -24968,7 +24956,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.41.0",
|
"playwright": "1.43.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",
|
||||||
@ -24976,23 +24964,10 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/browser-chromium": "1.41.0",
|
"@playwright/browser-chromium": "1.43.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",
|
||||||
@ -25010,18 +24985,6 @@
|
|||||||
"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",
|
||||||
@ -25402,11 +25365,13 @@
|
|||||||
"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.6.172",
|
"pdfjs-dist": "3.11.174",
|
||||||
|
"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.3.3",
|
"react-pdf": "7.7.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",
|
||||||
@ -25423,6 +25388,43 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -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.41.0",
|
"playwright": "1.43.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"
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import {
|
|||||||
ZDeleteFieldMutationSchema,
|
ZDeleteFieldMutationSchema,
|
||||||
ZDeleteRecipientMutationSchema,
|
ZDeleteRecipientMutationSchema,
|
||||||
ZDownloadDocumentSuccessfulSchema,
|
ZDownloadDocumentSuccessfulSchema,
|
||||||
|
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||||
|
ZGenerateDocumentFromTemplateMutationSchema,
|
||||||
ZGetDocumentsQuerySchema,
|
ZGetDocumentsQuerySchema,
|
||||||
ZSendDocumentForSigningMutationSchema,
|
ZSendDocumentForSigningMutationSchema,
|
||||||
ZSuccessfulDocumentResponseSchema,
|
ZSuccessfulDocumentResponseSchema,
|
||||||
@ -85,6 +87,24 @@ 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: {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
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 { NEXT_PUBLIC_WEBAPP_URL } 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 { 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';
|
||||||
@ -19,10 +21,12 @@ 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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import {
|
import {
|
||||||
getPresignGetUrl,
|
getPresignGetUrl,
|
||||||
getPresignPostUrl,
|
getPresignPostUrl,
|
||||||
@ -73,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
...document,
|
...document,
|
||||||
recipients,
|
recipients: recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -229,6 +236,13 @@ 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,
|
||||||
@ -248,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -279,7 +295,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 createDocumentFromTemplate({
|
const document = await createDocumentFromTemplateLegacy({
|
||||||
templateId,
|
templateId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
@ -296,7 +312,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
formValues: body.formValues,
|
formValues: body.formValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newDocumentData = await putFile({
|
const newDocumentData = await putPdfFile({
|
||||||
name: fileName,
|
name: fileName,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||||
@ -324,10 +340,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
await upsertDocumentMeta({
|
await upsertDocumentMeta({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
subject: body.meta.subject,
|
...body.meta,
|
||||||
message: body.meta.message,
|
|
||||||
dateFormat: body.meta.dateFormat,
|
|
||||||
timezone: body.meta.timezone,
|
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -342,6 +355,89 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -349,6 +445,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
|
|
||||||
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const { id } = args.params;
|
const { id } = args.params;
|
||||||
|
const { sendEmail = true } = args.body ?? {};
|
||||||
|
|
||||||
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
||||||
|
|
||||||
@ -404,10 +501,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
|
|
||||||
await sendDocument({
|
const { Recipient: recipients, ...sentDocument } = await sendDocument({
|
||||||
documentId: Number(id),
|
documentId: Number(id),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
|
sendEmail,
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -415,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
message: 'Document sent for signing successfully',
|
message: 'Document sent for signing successfully',
|
||||||
|
...sentDocument,
|
||||||
|
recipients: recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -499,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...newRecipient,
|
...newRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -564,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...updatedRecipient,
|
...updatedRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@ -617,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...deletedRecipient,
|
...deletedRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZUrlSchema } from '@documenso/lib/schemas/common';
|
||||||
import {
|
import {
|
||||||
FieldType,
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
@ -44,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
|||||||
|
|
||||||
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
||||||
|
|
||||||
export const ZSendDocumentForSigningMutationSchema = null;
|
export const ZSendDocumentForSigningMutationSchema = z
|
||||||
|
.object({
|
||||||
|
sendEmail: z.boolean().optional().default(true),
|
||||||
|
})
|
||||||
|
.or(z.literal('').transform(() => ({ sendEmail: true })));
|
||||||
|
|
||||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
@ -88,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
recipientId: z.number(),
|
recipientId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -133,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@ -141,6 +152,61 @@ 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),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
@ -175,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
|||||||
readStatus: z.nativeEnum(ReadStatus),
|
readStatus: z.nativeEnum(ReadStatus),
|
||||||
signingStatus: z.nativeEnum(SigningStatus),
|
signingStatus: z.nativeEnum(SigningStatus),
|
||||||
sendStatus: z.nativeEnum(SendStatus),
|
sendStatus: z.nativeEnum(SendStatus),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||||
@ -225,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
|
|||||||
|
|
||||||
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||||
|
|
||||||
export const ZSuccessfulSigningResponseSchema = z.object({
|
export const ZSuccessfulSigningResponseSchema = z
|
||||||
message: z.string(),
|
.object({
|
||||||
});
|
message: z.string(),
|
||||||
|
})
|
||||||
|
.and(ZSuccessfulGetDocumentResponseSchema);
|
||||||
|
|
||||||
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -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 account').getByText('Require account').click();
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
|
||||||
// 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,11 +52,7 @@ 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();
|
||||||
|
|
||||||
// Todo: Verify that the values are correct once we fix the issue where going back
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
// 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);
|
||||||
});
|
});
|
||||||
@ -89,8 +85,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 account').getByText('Require account').click();
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
|
||||||
// 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();
|
||||||
@ -168,11 +164,8 @@ 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();
|
||||||
|
|
||||||
// Todo: Verify that the values are correct once we fix the issue where going back
|
await expect(page.getByLabel('Title')).toHaveValue('New Title');
|
||||||
// does not show the updated values.
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
// 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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').click();
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
|
||||||
// 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,7 +62,6 @@ 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);
|
||||||
@ -93,26 +92,5 @@ 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,167 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@ -0,0 +1,285 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
@ -189,7 +189,14 @@ 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');
|
||||||
@ -200,7 +207,14 @@ 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`);
|
||||||
|
|||||||
BIN
packages/assets/fonts/noto-sans.ttf
Normal file
BIN
packages/assets/fonts/noto-sans.ttf
Normal file
Binary file not shown.
@ -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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } 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 putFile({
|
const { id: documentDataId } = await putPdfFile({
|
||||||
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),
|
||||||
|
|||||||
@ -21,6 +21,7 @@ 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.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { APP_BASE_URL } from './app';
|
import { APP_BASE_URL } from './app';
|
||||||
|
|
||||||
export const DEFAULT_STANDARD_FONT_SIZE = 15;
|
export const DEFAULT_STANDARD_FONT_SIZE = 12;
|
||||||
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;
|
||||||
|
|||||||
2
packages/lib/constants/template.ts
Normal file
2
packages/lib/constants/template.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
|
||||||
|
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
|
||||||
@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@ -149,4 +150,24 @@ 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.41.0",
|
"playwright": "1.43.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.41.0"
|
"@playwright/browser-chromium": "1.43.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
packages/lib/schemas/common.ts
Normal file
12
packages/lib/schemas/common.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
const documents = await prisma.document.updateMany({
|
const haveAllRecipientsSigned = await prisma.document.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: document.id,
|
id: document.id,
|
||||||
Recipient: {
|
Recipient: {
|
||||||
@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
completedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (documents.count > 0) {
|
if (haveAllRecipientsSigned) {
|
||||||
await sealDocument({ documentId: document.id, requestMetadata });
|
await sealDocument({ documentId: document.id, requestMetadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -75,18 +75,20 @@ 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.update({
|
await prisma.recipient
|
||||||
where: {
|
.update({
|
||||||
documentId_email: {
|
where: {
|
||||||
documentId: document.id,
|
id: userRecipient.id,
|
||||||
email: user.email,
|
|
||||||
},
|
},
|
||||||
},
|
data: {
|
||||||
data: {
|
documentDeletedAt: new Date().toISOString(),
|
||||||
documentDeletedAt: new Date().toISOString(),
|
},
|
||||||
},
|
})
|
||||||
});
|
.catch(() => {
|
||||||
|
// Do nothing.
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return partial document for API v1 response.
|
// Return partial document for API v1 response.
|
||||||
|
|||||||
@ -110,7 +110,7 @@ export const resendDocument = async ({
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(
|
customBody: renderCustomEmailTemplate(
|
||||||
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
|
selfSigner && !customEmail?.message ? 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(customEmail.subject, customEmailTemplate)
|
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
|
||||||
: emailSubject,
|
: emailSubject,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
|
|||||||
@ -14,9 +14,10 @@ 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 { putFile } from '../../universal/upload/put-file';
|
import { putPdfFile } 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';
|
||||||
@ -40,6 +41,11 @@ 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,
|
||||||
@ -53,10 +59,6 @@ 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,
|
||||||
@ -92,22 +94,24 @@ 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 }).then(async (doc) =>
|
const certificate = await getCertificatePdf({ documentId })
|
||||||
PDFDocument.load(doc),
|
.then(async (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);
|
||||||
doc.getForm().flatten();
|
flattenForm(doc);
|
||||||
flattenAnnotations(doc);
|
flattenAnnotations(doc);
|
||||||
|
|
||||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
if (certificate) {
|
||||||
|
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);
|
||||||
@ -119,7 +123,7 @@ export const sealDocument = async ({
|
|||||||
|
|
||||||
const { name, ext } = path.parse(document.title);
|
const { name, ext } = path.parse(document.title);
|
||||||
|
|
||||||
const { data: newData } = await putFile({
|
const { data: newData } = await putPdfFile({
|
||||||
name: `${name}_signed${ext}`,
|
name: `${name}_signed${ext}`,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||||
@ -138,6 +142,16 @@ 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,
|
||||||
|
|||||||
@ -4,8 +4,11 @@ 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';
|
||||||
@ -18,7 +21,6 @@ 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';
|
||||||
|
|
||||||
@ -26,6 +28,7 @@ export type SendDocumentOptions = {
|
|||||||
documentId: number;
|
documentId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
sendEmail?: boolean;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -33,6 +36,7 @@ export const sendDocument = async ({
|
|||||||
documentId,
|
documentId,
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
|
sendEmail = true,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: SendDocumentOptions) => {
|
}: SendDocumentOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@ -100,7 +104,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 putFile({
|
const newDocumentData = await putPdfFile({
|
||||||
name: document.title,
|
name: document.title,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||||
@ -118,99 +122,128 @@ export const sendDocument = async ({
|
|||||||
Object.assign(document, result);
|
Object.assign(document, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
if (sendEmail) {
|
||||||
document.Recipient.map(async (recipient) => {
|
await Promise.all(
|
||||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
document.Recipient.map(async (recipient) => {
|
||||||
return;
|
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
const selfSigner = email === user.email;
|
const selfSigner = email === user.email;
|
||||||
|
|
||||||
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
||||||
recipient.role
|
recipient.role
|
||||||
].actionVerb.toLowerCase()} it.`;
|
].actionVerb.toLowerCase()} it.`;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
'signer.name': name,
|
'signer.name': name,
|
||||||
'signer.email': email,
|
'signer.email': email,
|
||||||
'document.name': document.title,
|
'document.name': document.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||||
|
|
||||||
const template = createElement(DocumentInviteEmailTemplate, {
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
documentName: document.title,
|
documentName: document.title,
|
||||||
inviterName: user.name || undefined,
|
inviterName: user.name || undefined,
|
||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(
|
customBody: renderCustomEmailTemplate(
|
||||||
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
|
selfSigner && !customEmail?.message
|
||||||
customEmailTemplate,
|
? selfSignerCustomEmail
|
||||||
),
|
: customEmail?.message || '',
|
||||||
role: recipient.role,
|
customEmailTemplate,
|
||||||
selfSigner,
|
),
|
||||||
});
|
role: recipient.role,
|
||||||
|
selfSigner,
|
||||||
|
});
|
||||||
|
|
||||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
const emailSubject = selfSigner
|
const emailSubject = selfSigner
|
||||||
? `Please ${actionVerb.toLowerCase()} your document`
|
? `Please ${actionVerb.toLowerCase()} your document`
|
||||||
: `Please ${actionVerb.toLowerCase()} this document`;
|
: `Please ${actionVerb.toLowerCase()} this document`;
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
name,
|
name,
|
||||||
},
|
|
||||||
from: {
|
|
||||||
name: FROM_NAME,
|
|
||||||
address: FROM_ADDRESS,
|
|
||||||
},
|
|
||||||
subject: customEmail?.subject
|
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
|
||||||
: emailSubject,
|
|
||||||
html: render(template),
|
|
||||||
text: render(template, { plainText: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.recipient.update({
|
|
||||||
where: {
|
|
||||||
id: recipient.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
sendStatus: SendStatus.SENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
|
||||||
documentId: document.id,
|
|
||||||
user,
|
|
||||||
requestMetadata,
|
|
||||||
data: {
|
|
||||||
emailType: recipientEmailType,
|
|
||||||
recipientEmail: recipient.email,
|
|
||||||
recipientName: recipient.name,
|
|
||||||
recipientRole: recipient.role,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
isResending: false,
|
|
||||||
},
|
},
|
||||||
}),
|
from: {
|
||||||
});
|
name: FROM_NAME,
|
||||||
},
|
address: FROM_ADDRESS,
|
||||||
{ timeout: 30_000 },
|
},
|
||||||
);
|
subject: customEmail?.subject
|
||||||
}),
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
|
: emailSubject,
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
documentId: document.id,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
emailType: recipientEmailType,
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
isResending: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,22 +1,19 @@
|
|||||||
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: Field[];
|
fields: {
|
||||||
|
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 ({
|
||||||
@ -58,11 +55,7 @@ export const setFieldsForTemplate = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const removedFields = existingFields.filter(
|
const removedFields = existingFields.filter(
|
||||||
(existingField) =>
|
(existingField) => !fields.find((field) => field.id === existingField.id),
|
||||||
!fields.find(
|
|
||||||
(field) =>
|
|
||||||
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkedFields = fields.map((field) => {
|
const linkedFields = fields.map((field) => {
|
||||||
@ -127,5 +120,13 @@ export const setFieldsForTemplate = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedFields;
|
// Filter out fields that have been removed or have been updated.
|
||||||
|
const filteredFields = existingFields.filter((field) => {
|
||||||
|
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||||
|
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||||
|
|
||||||
|
return !isRemoved && !isUpdated;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...filteredFields, ...persistedFields];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,7 +18,9 @@ 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) {
|
||||||
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
|
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
|
||||||
|
// !: 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();
|
||||||
}
|
}
|
||||||
@ -33,6 +35,7 @@ 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({
|
||||||
|
|||||||
112
packages/lib/server-only/pdf/flatten-form.ts
Normal file
112
packages/lib/server-only/pdf/flatten-form.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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, StandardFonts } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||||
@ -17,6 +17,10 @@ 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);
|
||||||
@ -41,7 +45,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 : StandardFonts.Helvetica);
|
const font = await pdf.embedFont(isSignatureField ? fontCaveat : fontNoto);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@ -6,6 +7,8 @@ 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: {
|
||||||
@ -15,7 +18,10 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (teamMember?.role !== TeamMemberRole.ADMIN) {
|
if (teamMember?.role !== TeamMemberRole.ADMIN) {
|
||||||
throw new Error('You do not have permission to view tokens for this team');
|
throw new AppError(
|
||||||
|
AppErrorCode.UNAUTHORIZED,
|
||||||
|
'You do not have the required permissions to view this page.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.apiToken.findMany({
|
return await prisma.apiToken.findMany({
|
||||||
|
|||||||
@ -1,21 +1,32 @@
|
|||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { RecipientRole } from '@documenso/prisma/client';
|
import type { Recipient } 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) => {
|
||||||
@ -43,6 +54,23 @@ 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(),
|
||||||
@ -74,31 +102,59 @@ export const setRecipientsForTemplate = async ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedRecipients = await prisma.$transaction(
|
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||||
// Disabling as wrapping promises here causes type issues
|
return await Promise.all(
|
||||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
linkedRecipients.map(async (recipient) => {
|
||||||
linkedRecipients.map((recipient) =>
|
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
|
||||||
prisma.recipient.upsert({
|
|
||||||
where: {
|
if (recipient.actionAuth !== undefined) {
|
||||||
id: recipient._persisted?.id ?? -1,
|
authOptions = createRecipientAuthOptions({
|
||||||
templateId,
|
accessAuth: authOptions.accessAuth,
|
||||||
},
|
actionAuth: recipient.actionAuth,
|
||||||
update: {
|
});
|
||||||
name: recipient.name,
|
}
|
||||||
email: recipient.email,
|
|
||||||
role: recipient.role,
|
const upsertedRecipient = await tx.recipient.upsert({
|
||||||
templateId,
|
where: {
|
||||||
},
|
id: recipient._persisted?.id ?? -1,
|
||||||
create: {
|
templateId,
|
||||||
name: recipient.name,
|
},
|
||||||
email: recipient.email,
|
update: {
|
||||||
role: recipient.role,
|
name: recipient.name,
|
||||||
token: nanoid(),
|
email: recipient.email,
|
||||||
templateId,
|
role: recipient.role,
|
||||||
},
|
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({
|
||||||
@ -110,5 +166,17 @@ export const setRecipientsForTemplate = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedRecipients;
|
// Filter out recipients that have been removed or have been updated.
|
||||||
|
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
||||||
|
const isRemoved = removedRecipients.find(
|
||||||
|
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||||
|
);
|
||||||
|
const isUpdated = persistedRecipients.find(
|
||||||
|
(persistedRecipient) => persistedRecipient.id === recipient.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return !isRemoved && !isUpdated;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...filteredRecipients, ...persistedRecipients];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,144 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@ -1,16 +1,52 @@
|
|||||||
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 { RecipientRole } from '@documenso/prisma/client';
|
import type { Field } 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 ({
|
||||||
@ -18,7 +54,15 @@ 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,
|
||||||
@ -39,16 +83,51 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: {
|
||||||
Field: true,
|
include: {
|
||||||
|
Field: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
templateDocumentData: true,
|
templateDocumentData: true,
|
||||||
|
templateMeta: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new Error('Template not found.');
|
throw new AppError(AppErrorCode.NOT_FOUND, '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,
|
||||||
@ -57,81 +136,104 @@ export const createDocumentFromTemplate = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const document = await prisma.document.create({
|
return await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const document = await tx.document.create({
|
||||||
userId,
|
data: {
|
||||||
teamId: template.teamId,
|
userId,
|
||||||
title: template.title,
|
teamId: template.teamId,
|
||||||
documentDataId: documentData.id,
|
title: override?.title || template.title,
|
||||||
Recipient: {
|
documentDataId: documentData.id,
|
||||||
create: template.Recipient.map((recipient) => ({
|
authOptions: createDocumentAuthOptions({
|
||||||
email: recipient.email,
|
globalAccessAuth: templateAuthOptions.globalAccessAuth,
|
||||||
name: recipient.name,
|
globalActionAuth: templateAuthOptions.globalActionAuth,
|
||||||
role: recipient.role,
|
}),
|
||||||
token: nanoid(),
|
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);
|
||||||
|
|
||||||
include: {
|
return {
|
||||||
Recipient: {
|
email: recipient.email,
|
||||||
orderBy: {
|
name: recipient.name,
|
||||||
id: 'asc',
|
role: recipient.role,
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: authOptions.accessAuth,
|
||||||
|
actionAuth: authOptions.actionAuth,
|
||||||
|
}),
|
||||||
|
token: nanoid(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
documentData: true,
|
include: {
|
||||||
},
|
Recipient: {
|
||||||
});
|
orderBy: {
|
||||||
|
id: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.field.createMany({
|
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
|
||||||
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);
|
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||||
|
const recipient = document.Recipient.find((recipient) => recipient.email === email);
|
||||||
|
|
||||||
return {
|
if (!recipient) {
|
||||||
type: field.type,
|
throw new Error('Recipient not found.');
|
||||||
page: field.page,
|
}
|
||||||
positionX: field.positionX,
|
|
||||||
positionY: field.positionY,
|
fieldsToCreate = fieldsToCreate.concat(
|
||||||
width: field.width,
|
fields.map((field) => ({
|
||||||
height: field.height,
|
documentId: document.id,
|
||||||
customText: field.customText,
|
recipientId: recipient.id,
|
||||||
inserted: field.inserted,
|
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,
|
documentId: document.id,
|
||||||
recipientId: documentRecipient?.id || null,
|
user,
|
||||||
};
|
requestMetadata,
|
||||||
}),
|
data: {
|
||||||
});
|
title: document.title,
|
||||||
|
},
|
||||||
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;
|
await triggerWebhook({
|
||||||
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
data: document,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return document;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -81,6 +81,10 @@ 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,
|
||||||
@ -91,7 +95,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 || null,
|
recipientId: duplicatedTemplateRecipient.id,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
139
packages/lib/server-only/template/update-template-settings.ts
Normal file
139
packages/lib/server-only/template/update-template-settings.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
'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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -4,6 +4,7 @@ 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',
|
||||||
|
|||||||
3
packages/lib/types/fields.ts
Normal file
3
packages/lib/types/fields.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
|
||||||
|
|
||||||
|
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];
|
||||||
@ -17,6 +17,7 @@ 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;
|
||||||
@ -25,7 +26,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);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
return await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
...requestHeaders,
|
...requestHeaders,
|
||||||
},
|
},
|
||||||
@ -35,9 +36,10 @@ 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(() => false);
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
return response;
|
return LOCAL_FEATURE_FLAGS[flag] ?? false;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,6 +52,7 @@ 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;
|
||||||
@ -67,7 +70,10 @@ 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(() => LOCAL_FEATURE_FLAGS);
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
return LOCAL_FEATURE_FLAGS;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,7 +95,10 @@ 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(() => LOCAL_FEATURE_FLAGS);
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
return LOCAL_FEATURE_FLAGS;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GetFlagOptions {
|
interface GetFlagOptions {
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
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 = {
|
||||||
@ -12,14 +15,38 @@ 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');
|
||||||
|
|
||||||
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
return 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) => {
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- 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;
|
||||||
@ -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,15 +539,29 @@ 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)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ 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
|
||||||
@ -14,6 +15,32 @@ 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;
|
||||||
|
|
||||||
|
|||||||
19
packages/prisma/types/template.ts
Normal file
19
packages/prisma/types/template.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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[];
|
||||||
|
};
|
||||||
@ -124,10 +124,15 @@ module.exports = {
|
|||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
to: { height: 0 },
|
to: { height: 0 },
|
||||||
},
|
},
|
||||||
|
'caret-blink': {
|
||||||
|
'0%,70%,100%': { opacity: '1' },
|
||||||
|
'20%,50%': { opacity: '0' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||||
},
|
},
|
||||||
screens: {
|
screens: {
|
||||||
'3xl': '1920px',
|
'3xl': '1920px',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
@ -10,6 +11,7 @@ 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 {
|
||||||
@ -100,9 +102,13 @@ export const adminRouter = router({
|
|||||||
const { id } = input;
|
const { id } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await sealDocument({ documentId: id, isResealing: true });
|
const document = await getEntireDocument({ id });
|
||||||
|
|
||||||
|
const isResealing = document.status === DocumentStatus.COMPLETED;
|
||||||
|
|
||||||
|
return await sealDocument({ documentId: id, isResealing });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('resealDocument error', err);
|
console.error('resealDocument error', err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@ -123,7 +129,7 @@ export const adminRouter = router({
|
|||||||
|
|
||||||
return await deleteUser({ id });
|
return await deleteUser({ id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@ -144,7 +150,7 @@ export const adminRouter = router({
|
|||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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';
|
||||||
@ -20,6 +21,7 @@ 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 {
|
||||||
@ -221,10 +223,6 @@ 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 }) => {
|
||||||
@ -417,6 +415,10 @@ 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(),
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export const fieldRouter = router({
|
|||||||
const { templateId, fields } = input;
|
const { templateId, fields } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setFieldsForTemplate({
|
return await setFieldsForTemplate({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
templateId,
|
templateId,
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -46,16 +46,18 @@ export const recipientRouter = router({
|
|||||||
.input(ZAddTemplateSignersMutationSchema)
|
.input(ZAddTemplateSignersMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { templateId, signers } = input;
|
const { templateId, signers, teamId } = 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) {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ 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({
|
||||||
@ -41,6 +42,7 @@ 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(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } 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 putFile({
|
const { id: documentDataId } = await putPdfFile({
|
||||||
name: `${documentName}.pdf`,
|
name: `${documentName}.pdf`,
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
||||||
|
|||||||
@ -1,10 +1,16 @@
|
|||||||
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 {
|
||||||
@ -12,6 +18,8 @@ import {
|
|||||||
ZCreateTemplateMutationSchema,
|
ZCreateTemplateMutationSchema,
|
||||||
ZDeleteTemplateMutationSchema,
|
ZDeleteTemplateMutationSchema,
|
||||||
ZDuplicateTemplateMutationSchema,
|
ZDuplicateTemplateMutationSchema,
|
||||||
|
ZGetTemplateWithDetailsByIdQuerySchema,
|
||||||
|
ZUpdateTemplateSettingsMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const templateRouter = router({
|
export const templateRouter = router({
|
||||||
@ -49,19 +57,34 @@ export const templateRouter = router({
|
|||||||
throw new Error('You have reached your document limit.');
|
throw new Error('You have reached your document limit.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await createDocumentFromTemplate({
|
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
|
||||||
|
|
||||||
|
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 new TRPCError({
|
throw AppError.parseErrorToTRPCError(err);
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: 'We were unable to create this document. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@ -104,4 +127,52 @@ 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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user