mirror of
https://github.com/documenso/documenso.git
synced 2025-11-18 18:51:37 +10:00
Compare commits
92 Commits
feat/updat
...
feat/publi
| Author | SHA1 | Date | |
|---|---|---|---|
| 7746619dac | |||
| 8f9c07aa8e | |||
| cc4efddabf | |||
| 2ba0f48c61 | |||
| 5d5d0210fa | |||
| e50ccca766 | |||
| d7a3c40050 | |||
| dc11676d28 | |||
| e8d4fe46e5 | |||
| 64e3e2c64b | |||
| 23672d47f1 | |||
| c60bcc6309 | |||
| 69c175d38e | |||
| 15dee5ef35 | |||
| 28d6f6e2e8 | |||
| 78dc57a6eb | |||
| d3528f74f0 | |||
| dbd452be97 | |||
| 5109bb17d6 | |||
| 6974a76ed4 | |||
| 5efb0894e6 | |||
| cfec366c1a | |||
| 8622e68853 | |||
| 0e16a86e74 | |||
| 97d334a1da | |||
| 345e42537a | |||
| 8a24ca2065 | |||
| 06dd8219a5 | |||
| 74b9bc786b | |||
| 364c499927 | |||
| b0ce06f6fe | |||
| 20edee7f1a | |||
| 9bc5818d19 | |||
| 481d739c37 | |||
| 88dedc9829 | |||
| 4080806606 | |||
| e949fb14ae | |||
| e1573465f6 | |||
| 0062359977 | |||
| 03727bfad2 | |||
| c4a680caf7 | |||
| 1e33bc2aa3 | |||
| 4de122f814 | |||
| e4cf9c8251 | |||
| 41ed6c9ad7 | |||
| 713cd09a06 | |||
| 87423e240a | |||
| d7959950e2 | |||
| bb43547a45 | |||
| 3fb69422e8 | |||
| 4d5365bddc | |||
| 9298213177 | |||
| 0eee570781 | |||
| afaeba9739 | |||
| 4b90adde6b | |||
| f6e6dac46c | |||
| a97ffa97a4 | |||
| fceb0eaac9 | |||
| bd40e63392 | |||
| 6e09a4700b | |||
| 6526377f1b | |||
| f8ddb0f922 | |||
| 96e4797cdd | |||
| 3d3c53db02 | |||
| 8fe67e167c | |||
| 18b39eb538 | |||
| 3bc9b5ada0 | |||
| 1126fe4bff | |||
| db9899d293 | |||
| 0eeccfd643 | |||
| aa4b6f1723 | |||
| c8a09099a3 | |||
| 0f87dc047b | |||
| 80c758fb62 | |||
| 7705dbae0c | |||
| 8b58f10cbe | |||
| fe1f0e6a76 | |||
| a82975fd78 | |||
| a4967f19e8 | |||
| a311869c9b | |||
| 6f3cea52e8 | |||
| 732827f81d | |||
| bfff1234bb | |||
| 93a149d637 | |||
| f7ae3104ea | |||
| 64870f22b9 | |||
| 12e4bc918d | |||
| e36763a85d | |||
| 0bc9c590a7 | |||
| 4d4dfd3c5f | |||
| c9b4915fc8 | |||
| 110f9bae12 |
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
|
||||||
|
|||||||
@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p
|
|||||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||||
|
|
||||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||||
5. Place the certificate `/apps/web/resources/certificate.p12`
|
|
||||||
|
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
|
import type { TClaimPlanRequestSchema } from './types';
|
||||||
|
import { ZClaimPlanResponseSchema } from './types';
|
||||||
|
|
||||||
export const claimPlan = async ({
|
export const claimPlan = async ({
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
>
|
>
|
||||||
{showProfilesAnnouncementBar && (
|
{showProfilesAnnouncementBar && (
|
||||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
|
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
|
||||||
<div className="text-foreground text-center text-sm font-medium">
|
<div className="text-black text-center text-sm font-medium">
|
||||||
Claim your documenso public profile username now!{' '}
|
Claim your documenso public profile username now!{' '}
|
||||||
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
||||||
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
|||||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
dataKey={metricKey as string}
|
dataKey={metricKey as string}
|
||||||
maxBarSize={60}
|
maxBarSize={60}
|
||||||
fill="hsl(var(--primary))"
|
fill="hsl(var(--primary))"
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||||
const formattedData = data.map((item) => ({
|
const formattedData = data.map((item) => ({
|
||||||
amount: Number(item.amount),
|
amount: Number(item.amount),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
date: formatMonth(item.date as string),
|
date: formatMonth(item.date as string),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
import type { Variants } from 'framer-motion';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
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';
|
||||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
import { TOSSFriendsSchema } from './schema';
|
import type { TOSSFriendsSchema } from './schema';
|
||||||
|
|
||||||
const ContainerVariants: Variants = {
|
const ContainerVariants: Variants = {
|
||||||
initial: {
|
initial: {
|
||||||
|
|||||||
@ -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: {
|
||||||
@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
|||||||
expired: null,
|
expired: null,
|
||||||
signedAt: null,
|
signedAt: null,
|
||||||
readStatus: 'OPENED',
|
readStatus: 'OPENED',
|
||||||
|
documentDeletedAt: null,
|
||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
role: 'SIGNER',
|
role: 'SIGNER',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { MetadataRoute } from 'next';
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { MetadataRoute } from 'next';
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import LogoImage from '@documenso/assets/logo.png';
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
import { StatusWidgetContainer } from './status-widget-container';
|
// import { StatusWidgetContainer } from './status-widget-container';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
{/* <div className="mt-6">
|
||||||
<StatusWidgetContainer />
|
<StatusWidgetContainer />
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
variants={HeroTitleVariants}
|
variants={HeroTitleVariants}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
|
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
|
||||||
>
|
>
|
||||||
Document signing,
|
Document signing,
|
||||||
<span className="block" /> finally open source.
|
<span className="block" /> finally open source.
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
|
|||||||
export function StatusWidgetContainer() {
|
export function StatusWidgetContainer() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<StatusWidgetFallback />}>
|
<Suspense fallback={<StatusWidgetFallback />}>
|
||||||
<StatusWidget />
|
<StatusWidget slug="documenso-status" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { use, useMemo } from 'react';
|
import { memo, use } from 'react';
|
||||||
|
|
||||||
import type { Status } from '@openstatus/react';
|
import { type Status, getStatus } from '@openstatus/react';
|
||||||
import { getStatus } from '@openstatus/react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => {
|
|||||||
}[level];
|
}[level];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusWidget() {
|
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
|
||||||
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
|
const { status } = use(getStatus(slug));
|
||||||
const { status } = use(getStatusMemoized);
|
|
||||||
const level = getStatusLevel(status);
|
const level = getStatusLevel(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -72,4 +70,4 @@ export function StatusWidget() {
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
{signatureText && (
|
{signatureText && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
|
'text-foreground truncate text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{signatureText}
|
{signatureText}
|
||||||
@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="signatureText"
|
id="signatureText"
|
||||||
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
|
className="text-foreground placeholder:text-muted-foreground truncate border-none p-0 text-sm focus-visible:ring-0"
|
||||||
placeholder="Draw or type name here"
|
placeholder="Draw or type name here"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...register('signatureText', {
|
{...register('signatureText', {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { FieldError } from 'react-hook-form';
|
import type { FieldError } from 'react-hook-form';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { SVGAttributes } from 'react';
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const config = {
|
|||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,13 +4,22 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
Loader,
|
||||||
|
MoreHorizontal,
|
||||||
|
ScrollTextIcon,
|
||||||
|
Share,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
@ -32,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
|
|||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
||||||
@ -50,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
|
|
||||||
const isOwner = document.User.id === session.user.id;
|
const isOwner = document.User.id === session.user.id;
|
||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||||
|
const isDeleted = document.deletedAt !== null;
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||||
const isDocumentDeletable = isOwner;
|
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
@ -106,12 +116,22 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||||
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
|
Audit Log
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -138,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
<DeleteDocumentDialog
|
||||||
<DeleteDocumentDialog
|
id={document.id}
|
||||||
id={document.id}
|
status={document.status}
|
||||||
status={document.status}
|
documentTitle={document.title}
|
||||||
documentTitle={document.title}
|
open={isDeleteDialogOpen}
|
||||||
open={isDeleteDialogOpen}
|
canManageDocument={canManageDocument}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DuplicateDocumentDialog
|
<DuplicateDocumentDialog
|
||||||
id={document.id}
|
id={document.id}
|
||||||
|
|||||||
@ -8,17 +8,20 @@ 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';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
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,
|
||||||
@ -34,7 +37,7 @@ export type DocumentPageViewProps = {
|
|||||||
params: {
|
params: {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
team?: Team;
|
team?: Team & { teamEmail: TeamEmail | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||||
@ -83,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,
|
||||||
@ -118,11 +126,17 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -148,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">
|
||||||
|
|||||||
@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
@ -10,7 +10,6 @@ import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
|||||||
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Recipient, Team } from '@documenso/prisma/client';
|
import type { Recipient, Team } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card } from '@documenso/ui/primitives/card';
|
import { Card } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -19,6 +18,8 @@ import {
|
|||||||
} from '~/components/formatter/document-status';
|
} from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { DocumentLogsDataTable } from './document-logs-data-table';
|
import { DocumentLogsDataTable } from './document-logs-data-table';
|
||||||
|
import { DownloadAuditLogButton } from './download-audit-log-button';
|
||||||
|
import { DownloadCertificateButton } from './download-certificate-button';
|
||||||
|
|
||||||
export type DocumentLogsPageViewProps = {
|
export type DocumentLogsPageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -132,15 +133,13 @@ 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">
|
||||||
<Button variant="outline" className="mr-2 w-full sm:w-auto">
|
<DownloadCertificateButton
|
||||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
className="mr-2"
|
||||||
Download certificate
|
documentId={document.id}
|
||||||
</Button>
|
documentStatus={document.status}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button className="w-full sm:w-auto">
|
<DownloadAuditLogButton documentId={document.id} />
|
||||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
|
||||||
Download PDF
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DownloadAuditLogButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: downloadAuditLogs, isLoading } =
|
||||||
|
trpc.document.downloadAuditLogs.useMutation();
|
||||||
|
|
||||||
|
const onDownloadAuditLogsClick = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await downloadAuditLogs({ documentId });
|
||||||
|
|
||||||
|
const iframe = Object.assign(document.createElement('iframe'), {
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: '0',
|
||||||
|
height: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (iframe.contentDocument?.readyState === 'complete') {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
|
||||||
|
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||||
|
iframe.addEventListener('load', onLoaded);
|
||||||
|
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
onLoaded();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn('w-full sm:w-auto', className)}
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => void onDownloadAuditLogsClick()}
|
||||||
|
>
|
||||||
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
|
Download Audit Logs
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DownloadCertificateButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
documentId: number;
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DownloadCertificateButton = ({
|
||||||
|
className,
|
||||||
|
documentId,
|
||||||
|
documentStatus,
|
||||||
|
}: DownloadCertificateButtonProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: downloadCertificate, isLoading } =
|
||||||
|
trpc.document.downloadCertificate.useMutation();
|
||||||
|
|
||||||
|
const onDownloadCertificatesClick = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await downloadCertificate({ documentId });
|
||||||
|
|
||||||
|
const iframe = Object.assign(document.createElement('iframe'), {
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: '0',
|
||||||
|
height: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (iframe.contentDocument?.readyState === 'complete') {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
|
||||||
|
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||||
|
iframe.addEventListener('load', onLoaded);
|
||||||
|
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
onLoaded();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Sorry, we were unable to download the certificate. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn('w-full sm:w-auto', className)}
|
||||||
|
loading={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
||||||
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
|
>
|
||||||
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
|
Download Certificate
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -15,7 +15,6 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Share,
|
Share,
|
||||||
Trash2,
|
Trash2,
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
|
|||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||||
@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
// const isPending = row.status === DocumentStatus.PENDING;
|
// const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isDocumentDeletable = isOwner;
|
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger data-testid="document-table-action-btn">
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
{recipient && recipient?.role !== RecipientRole.CC && (
|
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
{recipient?.role === RecipientRole.VIEWER && (
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
{/* No point displaying this if there's no functionality. */}
|
||||||
|
{/* <DropdownMenuItem disabled>
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
Void
|
Void
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem> */}
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{canManageDocument ? 'Delete' : 'Hide'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
<DeleteDocumentDialog
|
||||||
<DeleteDocumentDialog
|
id={row.id}
|
||||||
id={row.id}
|
status={row.status}
|
||||||
status={row.status}
|
documentTitle={row.title}
|
||||||
documentTitle={row.title}
|
open={isDeleteDialogOpen}
|
||||||
open={isDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
teamId={team?.id}
|
||||||
teamId={team?.id}
|
canManageDocument={canManageDocument}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DuplicateDocumentDialog
|
<DuplicateDocumentDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
showSenderColumn?: boolean;
|
showSenderColumn?: boolean;
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({
|
export const DocumentsDataTable = ({
|
||||||
@ -76,7 +76,12 @@ export const DocumentsDataTable = ({
|
|||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
accessorKey: 'recipient',
|
accessorKey: 'recipient',
|
||||||
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
|
cell: ({ row }) => (
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={row.original.Recipient}
|
||||||
|
documentStatus={row.original.status}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
|
|||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
canManageDocument: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteDocumentDialog = ({
|
export const DeleteDocumentDialog = ({
|
||||||
@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
status,
|
status,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
teamId,
|
teamId,
|
||||||
|
canManageDocument,
|
||||||
}: DeleteDocumentDialogProps) => {
|
}: DeleteDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Please note that this action is irreversible. Once confirmed, your document will be
|
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
|
||||||
permanently deleted.
|
<strong>"{documentTitle}"</strong>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{status !== DocumentStatus.DRAFT && (
|
{canManageDocument ? (
|
||||||
<div className="mt-4">
|
<Alert variant="warning" className="-mt-1">
|
||||||
<Input
|
{match(status)
|
||||||
type="text"
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
value={inputValue}
|
<AlertDescription>
|
||||||
onChange={onInputChange}
|
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||||
placeholder="Type 'delete' to confirm"
|
this document will be permanently deleted.
|
||||||
/>
|
</AlertDescription>
|
||||||
</div>
|
))
|
||||||
|
.with(DocumentStatus.PENDING, () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
Please note that this action is <strong>irreversible</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1">Once confirmed, the following will occur:</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>Document will be permanently deleted</li>
|
||||||
|
<li>Document signing process will be cancelled</li>
|
||||||
|
<li>All inserted signatures will be voided</li>
|
||||||
|
<li>All recipients will be notified</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<p>By deleting this document, the following will occur:</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>The document will be hidden from your account</li>
|
||||||
|
<li>Recipients will still retain their copy of the document</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="warning" className="-mt-1">
|
||||||
|
<AlertDescription>
|
||||||
|
Please contact support if you would like to revert this action.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status !== DocumentStatus.DRAFT && canManageDocument && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder="Type 'delete' to confirm"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
<Button
|
Cancel
|
||||||
type="button"
|
</Button>
|
||||||
variant="secondary"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={!isDeleteEnabled}
|
disabled={!isDeleteEnabled && canManageDocument}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1"
|
>
|
||||||
>
|
{canManageDocument ? 'Delete' : 'Hide'}
|
||||||
Delete
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
|||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
||||||
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
|
const currentTeam = team
|
||||||
|
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const getStatOptions: GetStatsInput = {
|
const getStatOptions: GetStatsInput = {
|
||||||
user,
|
user,
|
||||||
|
|||||||
@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
<div
|
||||||
|
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
|
||||||
|
data-testid="empty-document-state"
|
||||||
|
>
|
||||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type PublicProfileSettingsLayout = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PublicProfileSettingsLayout({ children }: PublicProfileSettingsLayout) {
|
||||||
|
return <div className="col-span-12 md:col-span-9">{children}</div>;
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { LinkTemplatesForm } from '~/components/forms/link-templates';
|
||||||
|
import { PublicProfileForm } from '~/components/forms/public-profile';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Public profile',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PublicProfileSettingsPage() {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title="Public profile"
|
||||||
|
subtitle="You can choose to enable/disable your profile for public view"
|
||||||
|
className="justify mb-8"
|
||||||
|
hideDivider
|
||||||
|
>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<label className="mr-2 text-white" htmlFor="hide">
|
||||||
|
Hide
|
||||||
|
</label>
|
||||||
|
<Switch />
|
||||||
|
<label className="ml-2 text-white" htmlFor="show">
|
||||||
|
Show
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<PublicProfileForm className="mb-8 max-w-xl" user={user} />
|
||||||
|
|
||||||
|
<LinkTemplatesForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -141,6 +141,8 @@ export const EditTemplateForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
|
// Todo: Add when we setup template settings.
|
||||||
|
isTemplateOwnerEnterprise={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplateFieldsFormPartial
|
<AddTemplateFieldsFormPartial
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
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,7 +73,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditTemplateForm
|
<EditTemplateForm
|
||||||
className="mt-8"
|
className="mt-6"
|
||||||
template={template}
|
template={template}
|
||||||
user={user}
|
user={user}
|
||||||
recipients={templateRecipients}
|
recipients={templateRecipients}
|
||||||
|
|||||||
@ -12,13 +12,16 @@ 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 { 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 { 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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@ -34,7 +37,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} 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 { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZCreateTemplateFormSchema = z.object({
|
const ZCreateTemplateFormSchema = z.object({
|
||||||
@ -61,8 +63,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
resolver: zodResolver(ZCreateTemplateFormSchema),
|
resolver: zodResolver(ZCreateTemplateFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
trpc.template.createTemplate.useMutation();
|
|
||||||
|
|
||||||
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
||||||
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||||
@ -97,7 +98,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
const file: File = uploadedFile.file;
|
const file: File = uploadedFile.file;
|
||||||
|
|
||||||
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,
|
||||||
@ -140,6 +141,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showNewTemplateDialog) {
|
if (!showNewTemplateDialog) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setUploadedFile(null);
|
||||||
}
|
}
|
||||||
}, [form, showNewTemplateDialog]);
|
}, [form, showNewTemplateDialog]);
|
||||||
|
|
||||||
@ -154,20 +156,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
<DialogContent className="w-full max-w-xl">
|
<DialogContent className="w-full max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-4">New Template</DialogTitle>
|
<DialogTitle>New Template</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Templates allow you to quickly generate documents with pre-filled recipients and fields.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div>
|
<Form {...form}>
|
||||||
<Form {...form}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
<fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name your template</FormLabel>
|
<FormLabel>Template name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
@ -180,55 +185,57 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div className="mt-1.5">
|
||||||
<Label htmlFor="template">Upload a Document</Label>
|
{uploadedFile ? (
|
||||||
|
<Card gradient className="h-[40vh]">
|
||||||
|
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => resetForm()}
|
||||||
|
title="Remove Template"
|
||||||
|
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
<span className="sr-only">Remove Template</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="my-3">
|
<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">
|
||||||
{uploadedFile ? (
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||||
<Card gradient className="h-[40vh]">
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||||
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||||
<button
|
</div>
|
||||||
onClick={() => resetForm()}
|
|
||||||
title="Remove Template"
|
|
||||||
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
<span className="sr-only">Remove Template</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<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">
|
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
Uploaded Document
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
</p>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
<span className="text-muted-foreground/80 mt-1 text-sm">
|
||||||
Uploaded Document
|
{uploadedFile.file.name}
|
||||||
</p>
|
</span>
|
||||||
|
</CardContent>
|
||||||
<span className="text-muted-foreground/80 mt-1 text-sm">
|
</Card>
|
||||||
{uploadedFile.file.name}
|
) : (
|
||||||
</span>
|
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<DocumentDropzone
|
|
||||||
className="mt-1.5 h-[40vh]"
|
|
||||||
onDrop={onFileDrop}
|
|
||||||
type="template"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<DialogFooter>
|
||||||
<Button loading={isCreatingTemplate} type="submit">
|
<DialogClose asChild>
|
||||||
Create Template
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
disabled={!uploadedFile}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create template
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</fieldset>
|
||||||
</Form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
export type AuditLogDataTableProps = {
|
||||||
|
logs: TDocumentAuditLog[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormat: DateTimeFormatOptions = {
|
||||||
|
...DateTime.DATETIME_SHORT,
|
||||||
|
hourCycle: 'h12',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
const uppercaseFistLetter = (text: string) => {
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table overflowHidden>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>Browser</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody className="print:text-xs">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<TableRow className="break-inside-avoid" key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<LocaleDate format={dateFormat} date={log.createdAt} />
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{log.name || log.email ? (
|
||||||
|
<div>
|
||||||
|
{log.name && (
|
||||||
|
<p className="break-all" title={log.name}>
|
||||||
|
{log.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log.email && (
|
||||||
|
<p className="text-muted-foreground break-all" title={log.email}>
|
||||||
|
{log.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>N/A</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{log.ipAddress}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
||||||
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { AuditLogDataTable } from './data-table';
|
||||||
|
|
||||||
|
type AuditLogProps = {
|
||||||
|
searchParams: {
|
||||||
|
d: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||||
|
const { d } = searchParams;
|
||||||
|
|
||||||
|
if (typeof d !== 'string' || !d) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDocumentId = decryptSecondaryData(d);
|
||||||
|
|
||||||
|
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentId = Number(rawDocumentId);
|
||||||
|
|
||||||
|
const document = await getEntireDocument({
|
||||||
|
id: documentId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: auditLogs } = await findDocumentAuditLogs({
|
||||||
|
documentId: documentId,
|
||||||
|
userId: document.userId,
|
||||||
|
perPage: 100_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="my-8 text-2xl font-bold">Version History</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Document ID</span>
|
||||||
|
|
||||||
|
<span className="mt-1 block break-words">{document.id}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Enclosed Document</span>
|
||||||
|
|
||||||
|
<span className="mt-1 block break-words">{document.title}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Status</span>
|
||||||
|
|
||||||
|
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Owner</span>
|
||||||
|
|
||||||
|
<span className="mt-1 block break-words">
|
||||||
|
{document.User.name} ({document.User.email})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Created At</span>
|
||||||
|
|
||||||
|
<span className="mt-1 block">
|
||||||
|
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Last Updated</span>
|
||||||
|
|
||||||
|
<span className="mt-1 block">
|
||||||
|
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Time Zone</span>
|
||||||
|
|
||||||
|
<span className="mt-1 block break-words">
|
||||||
|
{document.documentMeta?.timezone ?? 'N/A'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Recipients</p>
|
||||||
|
|
||||||
|
<ul className="mt-1 list-inside list-disc">
|
||||||
|
{document.Recipient.map((recipient) => (
|
||||||
|
<li key={recipient.id}>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
|
||||||
|
</span>{' '}
|
||||||
|
{recipient.name} ({recipient.email})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<AuditLogDataTable logs={auditLogs} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="my-8 flex-row-reverse">
|
||||||
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
|
<Logo className="max-h-6 print:max-h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
|
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||||
|
} from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
type SigningCertificateProps = {
|
||||||
|
searchParams: {
|
||||||
|
d: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRIENDLY_SIGNING_REASONS = {
|
||||||
|
['__OWNER__']: 'I am the owner of this document',
|
||||||
|
...RECIPIENT_ROLE_SIGNING_REASONS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
|
||||||
|
const { d } = searchParams;
|
||||||
|
|
||||||
|
if (typeof d !== 'string' || !d) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDocumentId = decryptSecondaryData(d);
|
||||||
|
|
||||||
|
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentId = Number(rawDocumentId);
|
||||||
|
|
||||||
|
const document = await getEntireDocument({
|
||||||
|
id: documentId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||||
|
id: documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOwner = (email: string) => {
|
||||||
|
return email.toLowerCase() === document.User.email.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDevice = (userAgent?: string | null) => {
|
||||||
|
if (!userAgent) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new UAParser(userAgent);
|
||||||
|
|
||||||
|
parser.setUA(userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthenticationLevel = (recipientId: number) => {
|
||||||
|
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedAuthMethods = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
||||||
|
.with('ACCOUNT', () => 'Account Re-Authentication')
|
||||||
|
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
|
||||||
|
.with('PASSKEY', () => 'Passkey Re-Authentication')
|
||||||
|
.with('EXPLICIT_NONE', () => 'Email')
|
||||||
|
.with(null, () => null)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
if (!authLevel) {
|
||||||
|
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
||||||
|
.with('ACCOUNT', () => 'Account Authentication')
|
||||||
|
.with(null, () => 'Email')
|
||||||
|
.exhaustive();
|
||||||
|
}
|
||||||
|
|
||||||
|
return authLevel;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecipientAuditLogs = (recipientId: number) => {
|
||||||
|
return {
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
|
||||||
|
(log) =>
|
||||||
|
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
|
||||||
|
),
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
|
||||||
|
].filter(
|
||||||
|
(log) =>
|
||||||
|
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
|
||||||
|
log.data.recipientId === recipientId,
|
||||||
|
),
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
|
||||||
|
].filter(
|
||||||
|
(log) =>
|
||||||
|
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
||||||
|
log.data.recipientId === recipientId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecipientSignatureField = (recipientId: number) => {
|
||||||
|
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
|
||||||
|
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table overflowHidden>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Signer Events</TableHead>
|
||||||
|
<TableHead>Signature</TableHead>
|
||||||
|
<TableHead>Details</TableHead>
|
||||||
|
{/* <TableHead>Security</TableHead> */}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody className="print:text-xs">
|
||||||
|
{document.Recipient.map((recipient, i) => {
|
||||||
|
const logs = getRecipientAuditLogs(recipient.id);
|
||||||
|
const signature = getRecipientSignatureField(recipient.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={i} className="print:break-inside-avoid">
|
||||||
|
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
|
||||||
|
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||||
|
<div className="break-all">{recipient.email}</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||||
|
<span className="font-medium">Authentication Level:</span>{' '}
|
||||||
|
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||||
|
{signature ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="inline-block rounded-lg p-1"
|
||||||
|
style={{
|
||||||
|
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||||
|
alt="Signature"
|
||||||
|
className="max-h-12 max-w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||||
|
<span className="font-medium">Signature ID:</span>{' '}
|
||||||
|
<span className="block font-mono uppercase">
|
||||||
|
{signature.secondaryId}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||||
|
<span className="font-medium">IP Address:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||||
|
<span className="font-medium">Device:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">N/A</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
|
<span className="font-medium">Sent:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{logs.EMAIL_SENT[0] ? (
|
||||||
|
<LocaleDate
|
||||||
|
date={logs.EMAIL_SENT[0].createdAt}
|
||||||
|
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'Unknown'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
|
<span className="font-medium">Viewed:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{logs.DOCUMENT_OPENED[0] ? (
|
||||||
|
<LocaleDate
|
||||||
|
date={logs.DOCUMENT_OPENED[0].createdAt}
|
||||||
|
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'Unknown'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
|
<span className="font-medium">Signed:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
|
||||||
|
<LocaleDate
|
||||||
|
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
|
||||||
|
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'Unknown'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
|
<span className="font-medium">Reason:</span>{' '}
|
||||||
|
<span className="inline-block">
|
||||||
|
{isOwner(recipient.email)
|
||||||
|
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||||
|
: FRIENDLY_SIGNING_REASONS[recipient.role]}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="my-8 flex-row-reverse">
|
||||||
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
|
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||||
|
Signing certificate provided by:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Logo className="max-h-6 print:max-h-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type ClaimAccountProps = {
|
||||||
|
defaultName: string;
|
||||||
|
defaultEmail: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZClaimAccountFormSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
password: ZPasswordSchema,
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const { name, email, password } = data;
|
||||||
|
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Password should not be common or based on personal information',
|
||||||
|
path: ['password'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TClaimAccountFormSchema = z.infer<typeof ZClaimAccountFormSchema>;
|
||||||
|
|
||||||
|
export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TClaimAccountFormSchema>({
|
||||||
|
values: {
|
||||||
|
name: defaultName ?? '',
|
||||||
|
email: defaultEmail,
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZClaimAccountFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
|
||||||
|
try {
|
||||||
|
await signup({ name, email, password });
|
||||||
|
|
||||||
|
router.push(`/unverified-account`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Registration Successful',
|
||||||
|
description:
|
||||||
|
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics.capture('App: User Claim Account', {
|
||||||
|
email,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to sign you up. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 w-full">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Enter your name" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="email"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-4">
|
||||||
|
<FormLabel>Email address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Enter your email" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-4">
|
||||||
|
<FormLabel>Set a password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput {...field} placeholder="Pick a password" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
|
||||||
|
Claim account
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
|
|||||||
|
|
||||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { env } from 'next-runtime-env';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
|
|||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
import { SigningAuthPageView } from '../signing-auth-page';
|
import { SigningAuthPageView } from '../signing-auth-page';
|
||||||
|
import { ClaimAccount } from './claim-account';
|
||||||
import { DocumentPreviewButton } from './document-preview-button';
|
import { DocumentPreviewButton } from './document-preview-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
|
|||||||
export default async function CompletedSigningPage({
|
export default async function CompletedSigningPage({
|
||||||
params: { token },
|
params: { token },
|
||||||
}: CompletedSigningPageProps) {
|
}: CompletedSigningPageProps) {
|
||||||
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
|
|||||||
|
|
||||||
const sessionData = await getServerSession();
|
const sessionData = await getServerSession();
|
||||||
const isLoggedIn = !!sessionData?.user;
|
const isLoggedIn = !!sessionData?.user;
|
||||||
|
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
<div
|
||||||
{/* Card with recipient */}
|
className={cn(
|
||||||
<SigningCard3D
|
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||||
name={recipientName}
|
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||||
signature={signatures.at(0)}
|
)}
|
||||||
signingCelebrationImage={signingCelebration}
|
>
|
||||||
/>
|
<div
|
||||||
|
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||||
|
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||||
|
canSignUp,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col items-center', {
|
||||||
|
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||||
|
{truncatedTitle}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
{/* Card with recipient */}
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
<SigningCard3D
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
name={recipientName}
|
||||||
<div className="text-documenso-700 flex items-center text-center">
|
signature={signatures.at(0)}
|
||||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
signingCelebrationImage={signingCelebration}
|
||||||
<span className="text-sm">Everyone has signed</span>
|
/>
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.with({ deletedAt: null }, () => (
|
|
||||||
<div className="flex items-center text-center text-blue-600">
|
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
|
||||||
<span className="text-sm">Waiting for others to sign</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<div className="flex items-center text-center text-red-600">
|
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
|
||||||
<span className="text-sm">Document no longer available to sign</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
You have
|
Document
|
||||||
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
{recipient.role === RecipientRole.SIGNER && ' Signed '}
|
||||||
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
|
||||||
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
{recipient.role === RecipientRole.APPROVER && ' Approved '}
|
||||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
</h2>
|
||||||
</h2>
|
|
||||||
|
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
||||||
Everyone has signed! You will receive an Email copy of the signed document.
|
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||||
</p>
|
<span className="text-sm">Everyone has signed</span>
|
||||||
))
|
</div>
|
||||||
.with({ deletedAt: null }, () => (
|
))
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
.with({ deletedAt: null }, () => (
|
||||||
You will receive an Email copy of the signed document once everyone has signed.
|
<div className="flex items-center mt-4 text-center text-blue-600">
|
||||||
</p>
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
))
|
<span className="text-sm">Waiting for others to sign</span>
|
||||||
.otherwise(() => (
|
</div>
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
))
|
||||||
This document has been cancelled by the owner and is no longer available for others to
|
.otherwise(() => (
|
||||||
sign.
|
<div className="flex items-center text-center text-red-600">
|
||||||
</p>
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
))}
|
<span className="text-sm">Document no longer available to sign</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
Everyone has signed! You will receive an Email copy of the signed document.
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.with({ deletedAt: null }, () => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
You will receive an Email copy of the signed document once everyone has signed.
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
This document has been cancelled by the owner and is no longer available for others
|
||||||
|
to sign.
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
{document.status === DocumentStatus.COMPLETED ? (
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentDownloadButton
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
className="flex-1"
|
|
||||||
fileName={document.title}
|
{document.status === DocumentStatus.COMPLETED ? (
|
||||||
documentData={documentData}
|
<DocumentDownloadButton
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
className="flex-1"
|
||||||
/>
|
fileName={document.title}
|
||||||
) : (
|
documentData={documentData}
|
||||||
<DocumentPreviewButton
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
className="text-[11px]"
|
/>
|
||||||
title="Signatures will appear once the document has been completed"
|
) : (
|
||||||
documentData={documentData}
|
<DocumentPreviewButton
|
||||||
/>
|
className="text-[11px]"
|
||||||
)}
|
title="Signatures will appear once the document has been completed"
|
||||||
|
documentData={documentData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoggedIn ? (
|
{canSignUp && (
|
||||||
|
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
|
||||||
|
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
|
||||||
|
Need to sign documents?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
|
||||||
|
Create your account and start using state-of-the-art document signing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoggedIn && (
|
||||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||||
Go Back Home
|
Go Back Home
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
|
||||||
Want to send slick signing links like this one?{' '}
|
|
||||||
<Link
|
|
||||||
href="https://documenso.com"
|
|
||||||
className="text-documenso-700 hover:text-documenso-600"
|
|
||||||
>
|
|
||||||
Check out Documenso.
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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,9 +46,15 @@ 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 (!document || !document.documentData || !recipient) {
|
if (
|
||||||
|
!document ||
|
||||||
|
!document.documentData ||
|
||||||
|
!recipient ||
|
||||||
|
document.status === DocumentStatus.DRAFT
|
||||||
|
) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,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>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|||||||
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 { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
|
|||||||
|
|
||||||
export type AvatarWithRecipientProps = {
|
export type AvatarWithRecipientProps = {
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
||||||
|
|
||||||
const onRecipientClick = () => {
|
const onRecipientClick = () => {
|
||||||
if (!recipient.token) {
|
if (!signingToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: 'Copied to clipboard',
|
||||||
description: 'The signing link has been copied to your clipboard.',
|
description: 'The signing link has been copied to your clipboard.',
|
||||||
@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('my-1 flex items-center gap-2', {
|
className={cn('my-1 flex items-center gap-2', {
|
||||||
'cursor-pointer hover:underline': recipient.token,
|
'cursor-pointer hover:underline': signingToken,
|
||||||
})}
|
})}
|
||||||
role={recipient.token ? 'button' : undefined}
|
role={signingToken ? 'button' : undefined}
|
||||||
title={recipient.token && 'Click to copy signing link for sending to recipient'}
|
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||||
onClick={onRecipientClick}
|
onClick={onRecipientClick}
|
||||||
>
|
>
|
||||||
<StackAvatar
|
<StackAvatar
|
||||||
@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<div>
|
|
||||||
<div
|
<div
|
||||||
className="text-muted-foreground text-sm"
|
className="text-muted-foreground text-sm"
|
||||||
title="Click to copy signing link for sending to recipient"
|
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||||
>
|
>
|
||||||
<p>{recipient.email} </p>
|
<p>{recipient.email}</p>
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,33 +1,28 @@
|
|||||||
'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 { 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';
|
||||||
import { StackAvatars } from './stack-avatars';
|
import { StackAvatars } from './stack-avatars';
|
||||||
|
|
||||||
export type StackAvatarsWithTooltipProps = {
|
export type StackAvatarsWithTooltipProps = {
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
position?: 'top' | 'bottom';
|
position?: 'top' | 'bottom';
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StackAvatarsWithTooltip = ({
|
export const StackAvatarsWithTooltip = ({
|
||||||
|
documentStatus,
|
||||||
recipients,
|
recipients,
|
||||||
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',
|
||||||
);
|
);
|
||||||
@ -44,105 +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 key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
))}
|
key={recipient.id}
|
||||||
</div>
|
recipient={recipient}
|
||||||
)}
|
documentStatus={documentStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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 key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
))}
|
key={recipient.id}
|
||||||
</div>
|
recipient={recipient}
|
||||||
)}
|
documentStatus={documentStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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 key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
))}
|
key={recipient.id}
|
||||||
</div>
|
recipient={recipient}
|
||||||
)}
|
documentStatus={documentStatus}
|
||||||
</PopoverContent>
|
/>
|
||||||
</Popover>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverHover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
@ -18,7 +17,6 @@ import {
|
|||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
} from '@documenso/lib/constants/trpc';
|
} from '@documenso/lib/constants/trpc';
|
||||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@ -71,7 +69,6 @@ export type CommandMenuProps = {
|
|||||||
|
|
||||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOwner = useCallback(
|
|
||||||
(document: Document) => document.userId === session?.user.id,
|
|
||||||
[session?.user.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSigningLink = useCallback(
|
|
||||||
(recipients: Recipient[]) =>
|
|
||||||
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
|
|
||||||
[session?.user.email],
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
if (!searchDocumentsData) {
|
if (!searchDocumentsData) {
|
||||||
return [];
|
return [];
|
||||||
@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
|
|
||||||
return searchDocumentsData.map((document) => ({
|
return searchDocumentsData.map((document) => ({
|
||||||
label: document.title,
|
label: document.title,
|
||||||
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
path: document.path,
|
||||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
value: document.value,
|
||||||
}));
|
}));
|
||||||
}, [searchDocumentsData, isOwner, getSigningLink]);
|
}, [searchDocumentsData]);
|
||||||
|
|
||||||
const currentPage = pages[pages.length - 1];
|
const currentPage = pages[pages.length - 1];
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
@ -12,8 +10,6 @@ import { getRootHref } from '@documenso/lib/utils/params';
|
|||||||
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';
|
||||||
|
|
||||||
import { CommandMenu } from '../common/command-menu';
|
|
||||||
|
|
||||||
const navigationLinks = [
|
const navigationLinks = [
|
||||||
{
|
{
|
||||||
href: '/documents',
|
href: '/documents',
|
||||||
@ -25,13 +21,14 @@ const navigationLinks = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
setIsCommandMenuOpen: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||||
|
|
||||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||||
@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
||||||
onClick={() => setOpen((open) => !open)}
|
onClick={() => setIsCommandMenuOpen(true)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Search className="mr-2 h-5 w-5" />
|
<Search className="mr-2 h-5 w-5" />
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
<Logo className="h-6 w-auto" />
|
<Logo className="h-6 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<DesktopNav />
|
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
||||||
|
|
||||||
<div className="flex gap-x-4 md:ml-8">
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
<MenuSwitcher user={user} teams={teams} />
|
<MenuSwitcher user={user} teams={teams} />
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
<Button
|
<Button
|
||||||
data-testid="menu-switcher"
|
data-testid="menu-switcher"
|
||||||
variant="none"
|
variant="none"
|
||||||
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
|
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||||
>
|
>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||||
@ -102,12 +102,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
rightSideComponent={
|
rightSideComponent={
|
||||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||||
}
|
}
|
||||||
|
textSectionClassName="hidden lg:flex"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||||
align="end"
|
align="end"
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
<SheetContent className="flex w-full max-w-[350px] flex-col">
|
||||||
<Link href="/" onClick={handleMenuItemClick}>
|
<Link href="/" onClick={handleMenuItemClick}>
|
||||||
<Image
|
<Image
|
||||||
src={LogoImage}
|
src={LogoImage}
|
||||||
@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -101,6 +101,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<Link href="/settings/public-profile">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe2 className="mr-2 h-5 w-5" />
|
||||||
|
Public profile
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -104,6 +104,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<Link href="/settings/public-profile">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe2 className="mr-2 h-5 w-5" />
|
||||||
|
Public profile
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
apps/web/src/components/forms/link-templates.tsx
Normal file
26
apps/web/src/components/forms/link-templates.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { File } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export const LinkTemplatesForm = () => {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex max-w-xl flex-row items-center justify-between')}>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">My templates</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm md:mt-2">
|
||||||
|
Create templates to display in your public profile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="submit" variant="outline" className="self-end p-4">
|
||||||
|
<File className="mr-2" /> Link template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
182
apps/web/src/components/forms/public-profile.tsx
Normal file
182
apps/web/src/components/forms/public-profile.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export const ZPublicProfileFormSchema = z.object({
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.min(1, { message: 'Please enter a valid username.' })
|
||||||
|
.regex(/^[a-z0-9-]+$/, {
|
||||||
|
message: 'Username can only container alphanumeric characters and dashes.',
|
||||||
|
}),
|
||||||
|
bio: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(256, {
|
||||||
|
message: 'Bio cannot be longer than 256 characters.',
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
|
||||||
|
|
||||||
|
export type PublicProfileFormProps = {
|
||||||
|
className?: string;
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PublicProfileForm = ({ className, user }: PublicProfileFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TPublicProfileFormSchema>({
|
||||||
|
values: {
|
||||||
|
url: user.url || '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZPublicProfileFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchedBio = form.watch('bio');
|
||||||
|
|
||||||
|
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
|
||||||
|
|
||||||
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ url, bio }: TPublicProfileFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updatePublicProfile({
|
||||||
|
url,
|
||||||
|
bio,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Public profile updated',
|
||||||
|
description: 'Your public profile has been updated successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: err.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to sign you In. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
const profileUrl = `documenso.com/u/${user.url}`;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(profileUrl)
|
||||||
|
.then(() => {
|
||||||
|
toast({
|
||||||
|
title: 'URL Copied',
|
||||||
|
description: 'The profile URL has been copied to your clipboard.',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to copy the URL to clipboard.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<fieldset className="flex w-full flex-col gap-y-8" disabled={isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Public profile URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="bg-muted flex w-full items-center rounded pl-2">
|
||||||
|
<span className="text-xs">documenso.com/u/{user.url}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
className="flex items-center justify-center rounded p-2 hover:bg-gray-200"
|
||||||
|
aria-label="Copy URL"
|
||||||
|
>
|
||||||
|
<Copy className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="bio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<>
|
||||||
|
<Textarea placeholder="Write about yourself..." {...field} />
|
||||||
|
<div className="text-muted-foreground text-left text-sm">
|
||||||
|
{256 - (watchedBio?.length || 0)} characters remaining
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isSubmitting} className="self-end">
|
||||||
|
{isSubmitting ? 'Saving changes...' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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',
|
||||||
|
|||||||
85
package-lock.json
generated
85
package-lock.json
generated
@ -22,6 +22,7 @@
|
|||||||
"eslint-config-custom": "*",
|
"eslint-config-custom": "*",
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"lint-staged": "^15.2.2",
|
"lint-staged": "^15.2.2",
|
||||||
|
"playwright": "1.43.0",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"turbo": "^1.9.3"
|
"turbo": "^1.9.3"
|
||||||
@ -4701,13 +4702,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"
|
||||||
@ -4716,6 +4730,50 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test/node_modules/playwright": {
|
||||||
|
"version": "1.43.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
|
||||||
|
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.43.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||||
|
"version": "1.43.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
|
||||||
|
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "5.4.2",
|
"version": "5.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
|
||||||
@ -17615,12 +17673,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.40.0",
|
"version": "1.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
|
||||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.40.0"
|
"playwright-core": "1.43.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@ -17633,10 +17690,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.40.0",
|
"version": "1.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
|
||||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
|
||||||
"dev": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
@ -17648,7 +17704,6 @@
|
|||||||
"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",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -24926,6 +24981,7 @@
|
|||||||
"next-auth": "4.24.5",
|
"next-auth": "4.24.5",
|
||||||
"oslo": "^0.17.0",
|
"oslo": "^0.17.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"playwright": "1.43.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"remeda": "^1.27.1",
|
"remeda": "^1.27.1",
|
||||||
"stripe": "^12.7.0",
|
"stripe": "^12.7.0",
|
||||||
@ -24933,6 +24989,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/browser-chromium": "1.43.0",
|
||||||
"@types/luxon": "^3.3.1"
|
"@types/luxon": "^3.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"eslint-config-custom": "*",
|
"eslint-config-custom": "*",
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"lint-staged": "^15.2.2",
|
"lint-staged": "^15.2.2",
|
||||||
|
"playwright": "1.43.0",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"turbo": "^1.9.3"
|
"turbo": "^1.9.3"
|
||||||
@ -59,4 +60,4 @@
|
|||||||
"next": "14.0.3"
|
"next": "14.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ZDeleteDocumentMutationSchema,
|
ZDeleteDocumentMutationSchema,
|
||||||
ZDeleteFieldMutationSchema,
|
ZDeleteFieldMutationSchema,
|
||||||
ZDeleteRecipientMutationSchema,
|
ZDeleteRecipientMutationSchema,
|
||||||
|
ZDownloadDocumentSuccessfulSchema,
|
||||||
ZGetDocumentsQuerySchema,
|
ZGetDocumentsQuerySchema,
|
||||||
ZSendDocumentForSigningMutationSchema,
|
ZSendDocumentForSigningMutationSchema,
|
||||||
ZSuccessfulDocumentResponseSchema,
|
ZSuccessfulDocumentResponseSchema,
|
||||||
@ -51,6 +52,17 @@ export const ApiContractV1 = c.router(
|
|||||||
summary: 'Get a single document',
|
summary: 'Get a single document',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
downloadSignedDocument: {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/documents/:id/download',
|
||||||
|
responses: {
|
||||||
|
200: ZDownloadDocumentSuccessfulSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Download a signed document when the storage transport is S3',
|
||||||
|
},
|
||||||
|
|
||||||
createDocument: {
|
createDocument: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/v1/documents',
|
path: '/api/v1/documents',
|
||||||
|
|||||||
@ -19,11 +19,14 @@ 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 { 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 { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
import {
|
||||||
|
getPresignGetUrl,
|
||||||
|
getPresignPostUrl,
|
||||||
|
} from '@documenso/lib/universal/upload/server-actions';
|
||||||
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { ApiContractV1 } from './contract';
|
import { ApiContractV1 } from './contract';
|
||||||
@ -83,6 +86,68 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'Please make sure the storage transport is set to S3.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document || !document.documentDataId) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DocumentDataType.S3_PATH !== document.documentData.type) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Invalid document data type',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status !== DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is not completed yet.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = await getPresignGetUrl(document.documentData.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { downloadUrl: url },
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'Error downloading the document. Please try again.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
|
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const { id: documentId } = args.params;
|
const { id: documentId } = args.params;
|
||||||
|
|
||||||
@ -164,6 +229,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,
|
||||||
@ -214,7 +286,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,
|
||||||
@ -231,7 +303,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),
|
||||||
@ -259,10 +331,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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api';
|
|||||||
|
|
||||||
import { ApiContractV1 } from './contract';
|
import { ApiContractV1 } from './contract';
|
||||||
|
|
||||||
export const OpenAPIV1 = generateOpenApi(
|
export const OpenAPIV1 = Object.assign(
|
||||||
ApiContractV1,
|
generateOpenApi(
|
||||||
{
|
ApiContractV1,
|
||||||
info: {
|
{
|
||||||
title: 'Documenso API',
|
info: {
|
||||||
version: '1.0.0',
|
title: 'Documenso API',
|
||||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
version: '1.0.0',
|
||||||
|
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
|
setOperationId: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
{
|
{
|
||||||
setOperationId: true,
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
authorization: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'Authorization',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
authorization: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -53,6 +53,10 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
|
|||||||
key: z.string(),
|
key: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
||||||
|
downloadUrl: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
|
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
|
||||||
|
|
||||||
export const ZCreateDocumentMutationSchema = z.object({
|
export const ZCreateDocumentMutationSchema = z.object({
|
||||||
|
|||||||
@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
await page
|
await page
|
||||||
.getByRole('textbox', { name: 'Email', exact: true })
|
.getByRole('textbox', { name: 'Email', exact: true })
|
||||||
.fill('recipient2@documenso.com');
|
.fill('recipient2@documenso.com');
|
||||||
await page.getByRole('textbox', { name: 'Name', exact: true }).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').click();
|
||||||
@ -82,7 +82,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
|||||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
// Advanced settings should not be visible for non EE users.
|
// Advanced settings should not be visible for non EE users.
|
||||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||||
|
|||||||
@ -136,7 +136,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
|||||||
await page.getByPlaceholder('Name').fill('User 1');
|
await page.getByPlaceholder('Name').fill('User 1');
|
||||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
|
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
|
||||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2');
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
|||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
|
||||||
await page.waitForURL(`/sign/${token}/complete`);
|
await page.waitForURL(`/sign/${token}/complete`);
|
||||||
await expect(page.getByText('You have signed')).toBeVisible();
|
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||||
|
|
||||||
// Check if document has been signed
|
// Check if document has been signed
|
||||||
const { status: completedStatus } = await getDocumentByToken(token);
|
const { status: completedStatus } = await getDocumentByToken(token);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
|
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
|||||||
email: sender.email,
|
email: sender.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// open actions menu
|
// Open document action menu.
|
||||||
await page
|
await page
|
||||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||||
.getByRole('cell', { name: 'Download' })
|
.getByRole('cell', { name: 'Download' })
|
||||||
@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
|||||||
email: sender.email,
|
email: sender.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// open actions menu
|
// Open document action menu.
|
||||||
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||||
|
|
||||||
// delete document
|
// delete document
|
||||||
@ -135,20 +136,11 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||||
|
|
||||||
await page.goto(`/sign/${recipient.token}`);
|
|
||||||
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
|
||||||
|
|
||||||
await page.goto('/documents');
|
|
||||||
await page.waitForURL('/documents');
|
|
||||||
|
|
||||||
await apiSignout({ page });
|
await apiSignout({ page });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
|
test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const { sender } = await seedDeleteDocumentsTestRequirements();
|
const { sender } = await seedDeleteDocumentsTestRequirements();
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
|
|||||||
email: sender.email,
|
email: sender.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// open actions menu
|
// Open document action menu.
|
||||||
await page
|
await page
|
||||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||||
.getByRole('cell', { name: 'Edit' })
|
.getByTestId('document-table-action-btn')
|
||||||
.getByRole('button')
|
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// delete document
|
// delete document
|
||||||
@ -169,4 +160,155 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
|
|||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 0);
|
||||||
|
await checkDocumentTabCount(page, 'All', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
|
||||||
|
const { sender } = await seedDeleteDocumentsTestRequirements();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: sender.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open document action menu.
|
||||||
|
await page
|
||||||
|
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||||
|
.getByTestId('document-table-action-btn')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Delete document.
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
await checkDocumentTabCount(page, 'All', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: sender.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open document action menu.
|
||||||
|
await page
|
||||||
|
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||||
|
.getByTestId('document-table-action-btn')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Delete document.
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
await checkDocumentTabCount(page, 'All', 2);
|
||||||
|
|
||||||
|
// Sign into the recipient account.
|
||||||
|
await apiSignout({ page });
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipients[0].email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 0);
|
||||||
|
await checkDocumentTabCount(page, 'All', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
|
||||||
|
const recipientA = recipients[0];
|
||||||
|
const recipientB = recipients[1];
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipientA.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open document action menu.
|
||||||
|
await page
|
||||||
|
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||||
|
.getByTestId('document-table-action-btn')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Delete document.
|
||||||
|
await page.getByRole('menuitem', { name: 'Hide' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Hide' }).click();
|
||||||
|
|
||||||
|
// Open document action menu.
|
||||||
|
await page
|
||||||
|
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||||
|
.getByTestId('document-table-action-btn')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Delete document.
|
||||||
|
await page.getByRole('menuitem', { name: 'Hide' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Hide' }).click();
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 0);
|
||||||
|
await checkDocumentTabCount(page, 'All', 0);
|
||||||
|
|
||||||
|
// Sign into the sender account.
|
||||||
|
await apiSignout({ page });
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: sender.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts for sender.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
|
// Sign into the other recipient account.
|
||||||
|
await apiSignout({ page });
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipientB.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts for other recipient.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 0);
|
||||||
|
await checkDocumentTabCount(page, 'All', 2);
|
||||||
});
|
});
|
||||||
|
|||||||
17
packages/app-tests/e2e/fixtures/documents.ts
Normal file
17
packages/app-tests/e2e/fixtures/documents.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||||
|
await page.getByRole('tab', { name: tabName }).click();
|
||||||
|
|
||||||
|
if (tabName !== 'All') {
|
||||||
|
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
|
||||||
|
};
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
@ -7,24 +6,10 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se
|
|||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
|
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
|
||||||
await page.getByRole('tab', { name: tabName }).click();
|
|
||||||
|
|
||||||
if (tabName !== 'All') {
|
|
||||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||||
const { team, teamMember2 } = await seedTeamDocuments();
|
const { team, teamMember2 } = await seedTeamDocuments();
|
||||||
|
|
||||||
@ -245,24 +230,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
|||||||
await unseedTeam(team.url);
|
await unseedTeam(team.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
|
||||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
|
||||||
|
|
||||||
await apiSignin({
|
|
||||||
page,
|
|
||||||
email: currentUser.email,
|
|
||||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
|
||||||
|
|
||||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
|
||||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
|
||||||
|
|
||||||
await checkDocumentTabCount(page, 'Pending', 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||||
|
|
||||||
@ -280,3 +247,125 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
|||||||
|
|
||||||
await expect(page.getByRole('status')).toContainText('Document re-sent');
|
await expect(page.getByRole('status')).toContainText('Document re-sent');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||||
|
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: teamMember3.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
|
||||||
|
// Should be hidden for all team members.
|
||||||
|
await apiSignout({ page });
|
||||||
|
|
||||||
|
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||||
|
for (const user of [team.owner, teamEmailMember]) {
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
await checkDocumentTabCount(page, 'All', 4);
|
||||||
|
|
||||||
|
await apiSignout({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||||
|
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: teamMember3.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 1);
|
||||||
|
|
||||||
|
// Should be hidden for all team members.
|
||||||
|
await apiSignout({ page });
|
||||||
|
|
||||||
|
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||||
|
for (const user of [team.owner, teamEmailMember]) {
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 2);
|
||||||
|
await checkDocumentTabCount(page, 'All', 4);
|
||||||
|
|
||||||
|
await apiSignout({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||||
|
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: teamMember3.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('row').getByRole('button').nth(2).click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 0);
|
||||||
|
|
||||||
|
// Should be hidden for all team members.
|
||||||
|
await apiSignout({ page });
|
||||||
|
|
||||||
|
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||||
|
for (const user of [team.owner, teamEmailMember]) {
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 2);
|
||||||
|
await checkDocumentTabCount(page, 'All', 4);
|
||||||
|
|
||||||
|
await apiSignout({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
|
|||||||
<br />"{documentName}"
|
<br />"{documentName}"
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
All signatures have been voided.
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
You don't need to sign it anymore.
|
You don't need to sign it anymore.
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps {
|
|||||||
signDocumentLink: string;
|
signDocumentLink: string;
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
|
selfSigner: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateDocumentInvite = ({
|
export const TemplateDocumentInvite = ({
|
||||||
@ -19,6 +20,7 @@ export const TemplateDocumentInvite = ({
|
|||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
role,
|
role,
|
||||||
|
selfSigner,
|
||||||
}: TemplateDocumentInviteProps) => {
|
}: TemplateDocumentInviteProps) => {
|
||||||
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||||
|
|
||||||
@ -28,8 +30,19 @@ export const TemplateDocumentInvite = ({
|
|||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
{selfSigner ? (
|
||||||
<br />"{documentName}"
|
<>
|
||||||
|
{`Please ${actionVerb.toLowerCase()} your document`}
|
||||||
|
<br />
|
||||||
|
{`"${documentName}"`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{`${inviterName} has invited you to ${actionVerb.toLowerCase()}`}
|
||||||
|
<br />
|
||||||
|
{`"${documentName}"`}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
|||||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||||
customBody?: string;
|
customBody?: string;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
|
selfSigner?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentInviteEmailTemplate = ({
|
export const DocumentInviteEmailTemplate = ({
|
||||||
@ -32,10 +33,13 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
customBody,
|
customBody,
|
||||||
role,
|
role,
|
||||||
|
selfSigner = false,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
|
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
|
||||||
|
|
||||||
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
|
const previewText = selfSigner
|
||||||
|
? `Please ${action} your document ${documentName}`
|
||||||
|
: `${inviterName} has invited you to ${action} ${documentName}`;
|
||||||
|
|
||||||
const getAssetUrl = (path: string) => {
|
const getAssetUrl = (path: string) => {
|
||||||
return new URL(path, assetBaseUrl).toString();
|
return new URL(path, assetBaseUrl).toString();
|
||||||
@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
signDocumentLink={signDocumentLink}
|
signDocumentLink={signDocumentLink}
|
||||||
assetBaseUrl={assetBaseUrl}
|
assetBaseUrl={assetBaseUrl}
|
||||||
role={role}
|
role={role}
|
||||||
|
selfSigner={selfSigner}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
|||||||
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
|
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
|
||||||
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
|
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||||
|
[RecipientRole.SIGNER]: 'I am a signer of this document',
|
||||||
|
[RecipientRole.APPROVER]: 'I am an approver of this document',
|
||||||
|
[RecipientRole.CC]: 'I am required to recieve a copy of this document',
|
||||||
|
[RecipientRole.VIEWER]: 'I am a viewer of this document',
|
||||||
|
} satisfies Record<keyof typeof RecipientRole, string>;
|
||||||
|
|||||||
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,6 +39,7 @@
|
|||||||
"next-auth": "4.24.5",
|
"next-auth": "4.24.5",
|
||||||
"oslo": "^0.17.0",
|
"oslo": "^0.17.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"playwright": "1.43.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"remeda": "^1.27.1",
|
"remeda": "^1.27.1",
|
||||||
"stripe": "^12.7.0",
|
"stripe": "^12.7.0",
|
||||||
@ -46,6 +47,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/luxon": "^3.3.1"
|
"@types/luxon": "^3.3.1",
|
||||||
|
"@playwright/browser-chromium": "1.43.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
|
|||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
User: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
Recipient: {
|
Recipient: {
|
||||||
include: {
|
include: {
|
||||||
Field: {
|
Field: {
|
||||||
|
|||||||
@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
|
|||||||
|
|
||||||
const document = await getDocument({ token, documentId });
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (document.status !== DocumentStatus.PENDING) {
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} must be pending`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.Recipient.length === 0) {
|
if (document.Recipient.length === 0) {
|
||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { mailer } from '@documenso/email/mailer';
|
|||||||
import { render } from '@documenso/email/render';
|
import { render } from '@documenso/email/render';
|
||||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
@ -27,110 +28,180 @@ export const deleteDocument = async ({
|
|||||||
teamId,
|
teamId,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: DeleteDocumentOptions) => {
|
}: DeleteDocumentOptions) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
const document = await prisma.document.findUnique({
|
const document = await prisma.document.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
...(teamId
|
|
||||||
? {
|
|
||||||
team: {
|
|
||||||
id: teamId,
|
|
||||||
members: {
|
|
||||||
some: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
userId,
|
|
||||||
teamId: null,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: true,
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
User: true,
|
team: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!document) {
|
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
|
||||||
throw new Error('Document not found');
|
throw new Error('Document not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status, User: user } = document;
|
const isUserOwner = document.userId === userId;
|
||||||
|
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
|
||||||
|
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
// if the document is a draft, hard-delete
|
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
|
||||||
if (status === DocumentStatus.DRAFT) {
|
throw new Error('Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hard or soft deleting the actual document if user has permission.
|
||||||
|
if (isUserOwner || isUserTeamMember) {
|
||||||
|
await handleDocumentOwnerDelete({
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
await prisma.recipient
|
||||||
|
.update({
|
||||||
|
where: {
|
||||||
|
id: userRecipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
documentDeletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Do nothing.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return partial document for API v1 response.
|
||||||
|
return {
|
||||||
|
id: document.id,
|
||||||
|
userId: document.userId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
title: document.title,
|
||||||
|
status: document.status,
|
||||||
|
documentDataId: document.documentDataId,
|
||||||
|
createdAt: document.createdAt,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
completedAt: document.completedAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type HandleDocumentOwnerDeleteOptions = {
|
||||||
|
document: Document & {
|
||||||
|
Recipient: Recipient[];
|
||||||
|
documentMeta: DocumentMeta | null;
|
||||||
|
};
|
||||||
|
user: User;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentOwnerDelete = async ({
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
}: HandleDocumentOwnerDeleteOptions) => {
|
||||||
|
if (document.deletedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete completed documents.
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
// Currently redundant since deleting a document will delete the audit logs.
|
|
||||||
// However may be useful if we disassociate audit lgos and documents if required.
|
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
data: createDocumentAuditLogData({
|
data: createDocumentAuditLogData({
|
||||||
documentId: id,
|
documentId: document.id,
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||||
user,
|
user,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
data: {
|
data: {
|
||||||
type: 'HARD',
|
type: 'SOFT',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
|
return await tx.document.update({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the document is pending, send cancellation emails to all recipients
|
// Hard delete draft and pending documents.
|
||||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
const deletedDocument = await prisma.$transaction(async (tx) => {
|
||||||
await Promise.all(
|
// Currently redundant since deleting a document will delete the audit logs.
|
||||||
document.Recipient.map(async (recipient) => {
|
// However may be useful if we disassociate audit logs and documents if required.
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
|
||||||
|
|
||||||
const template = createElement(DocumentCancelTemplate, {
|
|
||||||
documentName: document.title,
|
|
||||||
inviterName: user.name || undefined,
|
|
||||||
inviterEmail: user.email,
|
|
||||||
assetBaseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
await mailer.sendMail({
|
|
||||||
to: {
|
|
||||||
address: recipient.email,
|
|
||||||
name: recipient.name,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
name: FROM_NAME,
|
|
||||||
address: FROM_ADDRESS,
|
|
||||||
},
|
|
||||||
subject: 'Document Cancelled',
|
|
||||||
html: render(template),
|
|
||||||
text: render(template, { plainText: true }),
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the document is not a draft, only soft-delete.
|
|
||||||
return await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
data: createDocumentAuditLogData({
|
data: createDocumentAuditLogData({
|
||||||
documentId: id,
|
documentId: document.id,
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||||
user,
|
user,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
data: {
|
data: {
|
||||||
type: 'SOFT',
|
type: 'HARD',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return await tx.document.update({
|
return await tx.document.delete({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id: document.id,
|
||||||
},
|
status: {
|
||||||
data: {
|
not: DocumentStatus.COMPLETED,
|
||||||
deletedAt: new Date().toISOString(),
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send cancellation emails to recipients.
|
||||||
|
await Promise.all(
|
||||||
|
document.Recipient.map(async (recipient) => {
|
||||||
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const template = createElement(DocumentCancelTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
inviterName: user.name || undefined,
|
||||||
|
inviterEmail: user.email,
|
||||||
|
assetBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: FROM_NAME,
|
||||||
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
|
subject: 'Document Cancelled',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return deletedDocument;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -94,24 +94,65 @@ export const findDocuments = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause: Prisma.DocumentWhereInput = {
|
let deletedFilter: Prisma.DocumentWhereInput = {
|
||||||
...termFilters,
|
|
||||||
...filters,
|
|
||||||
AND: {
|
AND: {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: ExtendedDocumentStatus.COMPLETED,
|
userId: user.id,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: {
|
Recipient: {
|
||||||
not: ExtendedDocumentStatus.COMPLETED,
|
some: {
|
||||||
|
email: user.email,
|
||||||
|
documentDeletedAt: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
deletedFilter = {
|
||||||
|
AND: {
|
||||||
|
OR: team.teamEmail
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
teamId: team.id,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
User: {
|
||||||
|
email: team.teamEmail.email,
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
email: team.teamEmail.email,
|
||||||
|
documentDeletedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
teamId: team.id,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause: Prisma.DocumentWhereInput = {
|
||||||
|
...termFilters,
|
||||||
|
...filters,
|
||||||
|
...deletedFilter,
|
||||||
|
};
|
||||||
|
|
||||||
if (period) {
|
if (period) {
|
||||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs';
|
||||||
|
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
|
||||||
|
export type GetDocumentCertificateAuditLogsOptions = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDocumentCertificateAuditLogs = async ({
|
||||||
|
id,
|
||||||
|
}: GetDocumentCertificateAuditLogsOptions) => {
|
||||||
|
const rawAuditLogs = await prisma.documentAuditLog.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: id,
|
||||||
|
type: {
|
||||||
|
in: [
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||||
|
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log));
|
||||||
|
|
||||||
|
const groupedAuditLogs = {
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
|
||||||
|
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||||
|
),
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||||
|
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||||
|
),
|
||||||
|
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
|
||||||
|
(log) =>
|
||||||
|
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
|
||||||
|
log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED,
|
||||||
|
),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return groupedAuditLogs;
|
||||||
|
};
|
||||||
@ -72,6 +72,7 @@ type GetCountsOption = {
|
|||||||
|
|
||||||
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
|
// Owner counts.
|
||||||
prisma.document.groupBy({
|
prisma.document.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
_count: {
|
_count: {
|
||||||
@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
|||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Not signed counts.
|
||||||
prisma.document.groupBy({
|
prisma.document.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
_count: {
|
_count: {
|
||||||
@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.NOT_SIGNED,
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdAt,
|
createdAt,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Has signed counts.
|
||||||
prisma.document.groupBy({
|
prisma.document.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
_count: {
|
_count: {
|
||||||
@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: ExtendedDocumentStatus.COMPLETED,
|
status: ExtendedDocumentStatus.COMPLETED,
|
||||||
@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: teamEmail,
|
email: teamEmail,
|
||||||
signingStatus: SigningStatus.NOT_SIGNED,
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: teamEmail,
|
email: teamEmail,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: teamEmail,
|
email: teamEmail,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
|||||||
@ -88,6 +88,11 @@ export const resendDocument = async ({
|
|||||||
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 selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
||||||
|
recipient.role
|
||||||
|
].actionVerb.toLowerCase()} it.`;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
'signer.name': name,
|
'signer.name': name,
|
||||||
@ -104,12 +109,20 @@ export const resendDocument = async ({
|
|||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
customBody: renderCustomEmailTemplate(
|
||||||
|
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
|
||||||
|
customEmailTemplate,
|
||||||
|
),
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
selfSigner,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
|
const emailSubject = selfSigner
|
||||||
|
? `Reminder: Please ${actionVerb.toLowerCase()} your document`
|
||||||
|
: `Reminder: Please ${actionVerb.toLowerCase()} this document`;
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
@ -122,8 +135,8 @@ 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)
|
||||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
: emailSubject,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,7 +14,8 @@ 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 { flattenAnnotations } from '../pdf/flatten-annotations';
|
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||||
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';
|
||||||
@ -39,6 +40,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,
|
||||||
@ -52,10 +58,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,
|
||||||
@ -91,6 +93,10 @@ 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) => 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
|
||||||
@ -98,6 +104,14 @@ export const sealDocument = async ({
|
|||||||
doc.getForm().flatten();
|
doc.getForm().flatten();
|
||||||
flattenAnnotations(doc);
|
flattenAnnotations(doc);
|
||||||
|
|
||||||
|
if (certificate) {
|
||||||
|
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||||
|
|
||||||
|
certificatePages.forEach((page) => {
|
||||||
|
doc.addPage(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
await insertFieldInPDF(doc, field);
|
await insertFieldInPDF(doc, field);
|
||||||
}
|
}
|
||||||
@ -108,7 +122,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),
|
||||||
@ -127,6 +141,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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user