mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
145 Commits
v1.5.4-rc.
...
fix/render
| Author | SHA1 | Date | |
|---|---|---|---|
| 05197993fa | |||
| 04cd5b58c2 | |||
| f7e9d1b3cb | |||
| ca077dd3a6 | |||
| 8e9287a7c1 | |||
| 93e816a5b4 | |||
| d3734ff344 | |||
| 0c18f27b3f | |||
| b394e99f7a | |||
| c21e30d689 | |||
| 9b92e38c52 | |||
| ac41086e1a | |||
| 94cf412f29 | |||
| 6650a1d72e | |||
| 82848e3d2e | |||
| 22b8c2044b | |||
| ef5d267e96 | |||
| 518ddea081 | |||
| 805758f716 | |||
| 04ebb26a0b | |||
| 9cb80aa0bc | |||
| 0985206088 | |||
| aadb22cdbf | |||
| 3e304b37b2 | |||
| 1f3df51371 | |||
| 6e2363d48c | |||
| 64bec5f29c | |||
| 311328471e | |||
| d58a88196a | |||
| f1c6fc6fb7 | |||
| babdbccbd3 | |||
| 3e634fd975 | |||
| 4c0b772fc9 | |||
| 24b228acf7 | |||
| e072e270f8 | |||
| d37edc4351 | |||
| a877c64aca | |||
| 2f86bb523b | |||
| 788933b75d | |||
| bbcbc56e70 | |||
| 8f9c07aa8e | |||
| cc4efddabf | |||
| 98672560ca | |||
| 6f6ed05569 | |||
| 5e3f55c616 | |||
| 968b116012 | |||
| 2ba0f48c61 | |||
| 5d5d0210fa | |||
| e50ccca766 | |||
| d7a3c40050 | |||
| dc11676d28 | |||
| e8d4fe46e5 | |||
| 55d8afe870 | |||
| 64e3e2c64b | |||
| e4620efa4a | |||
| 84bbcea7bb | |||
| 15dee5ef35 | |||
| 28d6f6e2e8 | |||
| 78dc57a6eb | |||
| d3528f74f0 | |||
| dbd452be97 | |||
| 5109bb17d6 | |||
| 6974a76ed4 | |||
| 5efb0894e6 | |||
| cfec366c1a | |||
| 8622e68853 | |||
| 6df525b670 | |||
| 0e16a86e74 | |||
| dca4b8eaec | |||
| db9e605031 | |||
| 97d334a1da | |||
| bde0f5893f | |||
| 6b5750c7bf | |||
| 917c83fc5f | |||
| e82e402540 | |||
| 345e42537a | |||
| 80c03fcf3f | |||
| c98c1b9467 | |||
| 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 | |||
| 732827f81d | |||
| f7ae3104ea | |||
| 870de02efa | |||
| a58a117056 | |||
| 918e9ddc0b | |||
| 94eee8b913 | |||
| 345c4b8b14 | |||
| 897f0dabde | |||
| d5867ae8de | |||
| 5391dd91b0 | |||
| 4855882ae6 | |||
| c08768a330 | |||
| 37e9db6626 |
@ -75,7 +75,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
|
||||
# OPTIONAL: Defines whether to force the use of TLS.
|
||||
NEXT_PRIVATE_SMTP_SECURE=
|
||||
# REQUIRED: Defines the sender name to use for the from address.
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
||||
# REQUIRED: Defines the email address to use as the from address.
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
||||
# OPTIONAL: The API key to use for Resend.com
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
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
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- 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
|
||||
run: npm run ci
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
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
|
||||
id: parse-comment
|
||||
uses: actions/github-script@v5
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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,
|
||||
owner: context.repo.owner,
|
||||
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:
|
||||
pull_request:
|
||||
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
|
||||
types: ['opened', 'ready_for_review']
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v4
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-pr-stale: 90
|
||||
|
||||
20
.gitpod.yml
20
.gitpod.yml
@ -6,7 +6,7 @@ tasks:
|
||||
set -a; source .env &&
|
||||
export NEXTAUTH_URL="$(gp url 3000)" &&
|
||||
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
||||
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
||||
command: npm run d
|
||||
|
||||
ports:
|
||||
@ -25,20 +25,10 @@ ports:
|
||||
- port: 2500
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
- port: 54320
|
||||
visibility: private
|
||||
- port: 54320
|
||||
visibility: private
|
||||
onOpen: ignore
|
||||
|
||||
|
||||
github:
|
||||
prebuilds:
|
||||
master: true
|
||||
pullRequests: true
|
||||
pullRequestsFromForks: true
|
||||
addCheck: true
|
||||
addComment: true
|
||||
addBadge: true
|
||||
|
||||
vscode:
|
||||
extensions:
|
||||
- aaron-bond.better-comments
|
||||
@ -47,9 +37,5 @@ vscode:
|
||||
- esbenp.prettier-vscode
|
||||
- mikestead.dotenv
|
||||
- unifiedjs.vscode-mdx
|
||||
- GitHub.copilot-chat
|
||||
- GitHub.copilot-labs
|
||||
- GitHub.copilot
|
||||
- GitHub.vscode-pull-request-github
|
||||
- Prisma.prisma
|
||||
- VisualStudioExptTeam.vscodeintellicode
|
||||
|
||||
@ -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`
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||
);
|
||||
|
||||
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
|
||||
);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
experimental: {
|
||||
@ -38,6 +42,7 @@ const config = {
|
||||
env: {
|
||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
|
||||
import type { TClaimPlanRequestSchema } from './types';
|
||||
import { ZClaimPlanResponseSchema } from './types';
|
||||
|
||||
export const claimPlan = async ({
|
||||
name,
|
||||
|
||||
@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
<Bar
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
dataKey={metricKey as string}
|
||||
maxBarSize={60}
|
||||
fill="hsl(var(--primary))"
|
||||
|
||||
@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||
const formattedData = data.map((item) => ({
|
||||
amount: Number(item.amount),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
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';
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
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 { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { TOSSFriendsSchema } from './schema';
|
||||
import type { TOSSFriendsSchema } from './schema';
|
||||
|
||||
const ContainerVariants: Variants = {
|
||||
initial: {
|
||||
|
||||
@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
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 { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const putFileData = await putFile(uploadedFile.file);
|
||||
const putFileData = await putPdfFile(uploadedFile.file);
|
||||
|
||||
const documentToken = await createSinglePlayerDocument({
|
||||
documentData: {
|
||||
@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
||||
expired: null,
|
||||
signedAt: null,
|
||||
readStatus: 'OPENED',
|
||||
documentDeletedAt: null,
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
role: 'SIGNER',
|
||||
@ -247,6 +248,7 @@ export const SinglePlayerClient = () => {
|
||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||
fields={fields}
|
||||
onSubmit={onFieldsSubmit}
|
||||
canGoBack={true}
|
||||
isDocumentPdfLoaded={true}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
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';
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ import LogoImage from '@documenso/assets/logo.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
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>;
|
||||
|
||||
@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{/* <div className="mt-6">
|
||||
<StatusWidgetContainer />
|
||||
</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">
|
||||
|
||||
@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
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,
|
||||
<span className="block" /> finally open source.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
|
||||
export function StatusWidgetContainer() {
|
||||
return (
|
||||
<Suspense fallback={<StatusWidgetFallback />}>
|
||||
<StatusWidget />
|
||||
<StatusWidget slug="documenso-status" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { use, useMemo } from 'react';
|
||||
import { memo, use } from 'react';
|
||||
|
||||
import type { Status } from '@openstatus/react';
|
||||
import { getStatus } from '@openstatus/react';
|
||||
import { type Status, getStatus } from '@openstatus/react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => {
|
||||
}[level];
|
||||
};
|
||||
|
||||
export function StatusWidget() {
|
||||
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
|
||||
const { status } = use(getStatusMemoized);
|
||||
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
|
||||
const { status } = use(getStatus(slug));
|
||||
const level = getStatusLevel(status);
|
||||
|
||||
return (
|
||||
@ -72,4 +70,4 @@ export function StatusWidget() {
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
{signatureText && (
|
||||
<p
|
||||
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}
|
||||
@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
>
|
||||
<Input
|
||||
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"
|
||||
disabled={isSubmitting}
|
||||
{...register('signatureText', {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
import type { SVGAttributes } from 'react';
|
||||
|
||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
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) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||
);
|
||||
|
||||
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
|
||||
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
|
||||
);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
@ -42,6 +46,7 @@ const config = {
|
||||
APP_VERSION: version,
|
||||
NEXT_PUBLIC_PROJECT: 'web',
|
||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
|
||||
56591
apps/web/public/pdf.worker.min.js
vendored
56591
apps/web/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@ -2,7 +2,8 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { type Document, DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -17,9 +18,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export type AdminActionsProps = {
|
||||
className?: string;
|
||||
document: Document;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export const AdminActions = ({ className, document }: AdminActionsProps) => {
|
||||
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
||||
@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isResealDocumentLoading}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
disabled={recipients.some(
|
||||
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||
)}
|
||||
onClick={() => resealDocument({ id: document.id })}
|
||||
>
|
||||
Reseal document
|
||||
|
||||
@ -53,7 +53,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
||||
|
||||
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
||||
|
||||
<AdminActions className="mt-2" document={document} />
|
||||
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
||||
|
||||
<hr className="my-4" />
|
||||
<h2 className="text-lg font-semibold">Recipients</h2>
|
||||
|
||||
@ -4,13 +4,22 @@ import { useState } from 'react';
|
||||
|
||||
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 { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
@ -32,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
|
||||
Recipient: Recipient[];
|
||||
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) => {
|
||||
@ -50,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
|
||||
const isOwner = document.User.id === session.user.id;
|
||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||
const isDeleted = document.deletedAt !== null;
|
||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
@ -106,12 +116,22 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
</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)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</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" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@ -138,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
<DeleteDocumentDialog
|
||||
id={document.id}
|
||||
status={document.status}
|
||||
documentTitle={document.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<DeleteDocumentDialog
|
||||
id={document.id}
|
||||
status={document.status}
|
||||
documentTitle={document.title}
|
||||
open={isDeleteDialogOpen}
|
||||
canManageDocument={canManageDocument}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
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 { 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 { 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 { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
@ -34,7 +37,7 @@ export type DocumentPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
team?: Team & { teamEmail: TeamEmail | null };
|
||||
};
|
||||
|
||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||
@ -83,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId,
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
});
|
||||
const [recipients, completedFields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
}),
|
||||
getCompletedFieldsForDocument({
|
||||
documentId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const documentWithRecipients = {
|
||||
...document,
|
||||
@ -118,11 +126,17 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<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>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -148,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
</CardContent>
|
||||
</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="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
|
||||
@ -332,6 +332,7 @@ export const EditDocumentForm = ({
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
/>
|
||||
|
||||
<AddSignersFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
|
||||
@ -36,11 +36,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const document = await getDocumentWithDetailsById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
@ -74,6 +69,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<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>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
|
||||
@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<DownloadCertificateButton className="mr-2" documentId={document.id} />
|
||||
<DownloadCertificateButton
|
||||
className="mr-2"
|
||||
documentId={document.id}
|
||||
documentStatus={document.status}
|
||||
/>
|
||||
|
||||
<DownloadAuditLogButton documentId={document.id} />
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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';
|
||||
@ -10,11 +11,13 @@ 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();
|
||||
|
||||
@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({
|
||||
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" />}
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
Pencil,
|
||||
Share,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||
@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
// const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger data-testid="document-table-action-btn">
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
{recipient && recipient?.role !== RecipientRole.CC && (
|
||||
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled>
|
||||
{/* No point displaying this if there's no functionality. */}
|
||||
{/* <DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
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" />
|
||||
Delete
|
||||
{canManageDocument ? 'Delete' : 'Hide'}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
<DeleteDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
)}
|
||||
<DeleteDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
teamId={team?.id}
|
||||
canManageDocument={canManageDocument}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={row.id}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
@ -29,7 +30,7 @@ export type DocumentsDataTableProps = {
|
||||
}
|
||||
>;
|
||||
showSenderColumn?: boolean;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({
|
||||
@ -62,7 +63,12 @@ export const DocumentsDataTable = ({
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
cell: ({ row }) => (
|
||||
<LocaleDate
|
||||
date={row.original.createdAt}
|
||||
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
@ -76,7 +82,12 @@ export const DocumentsDataTable = ({
|
||||
{
|
||||
header: 'Recipient',
|
||||
accessorKey: 'recipient',
|
||||
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
|
||||
cell: ({ row }) => (
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={row.original.Recipient}
|
||||
documentStatus={row.original.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
|
||||
@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
teamId?: number;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export const DeleteDocumentDialog = ({
|
||||
@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
|
||||
status,
|
||||
documentTitle,
|
||||
teamId,
|
||||
canManageDocument,
|
||||
}: DeleteDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that this action is irreversible. Once confirmed, your document will be
|
||||
permanently deleted.
|
||||
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
|
||||
<strong>"{documentTitle}"</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{status !== DocumentStatus.DRAFT && (
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder="Type 'delete' to confirm"
|
||||
/>
|
||||
</div>
|
||||
{canManageDocument ? (
|
||||
<Alert variant="warning" className="-mt-1">
|
||||
{match(status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<AlertDescription>
|
||||
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||
this document will be permanently deleted.
|
||||
</AlertDescription>
|
||||
))
|
||||
.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>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={onDelete}
|
||||
disabled={!isDeleteEnabled}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={onDelete}
|
||||
disabled={!isDeleteEnabled && canManageDocument}
|
||||
variant="destructive"
|
||||
>
|
||||
{canManageDocument ? 'Delete' : 'Hide'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
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 = {
|
||||
user,
|
||||
|
||||
@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||
}));
|
||||
|
||||
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} />
|
||||
|
||||
<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 { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
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 { 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 { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { type, data } = await putFile(file);
|
||||
const { type, data } = await putPdfFile(file);
|
||||
|
||||
const { id: documentDataId } = await createDocumentData({
|
||||
type,
|
||||
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
});
|
||||
|
||||
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (err) {
|
||||
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({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
||||
import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -19,52 +23,135 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
|
||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
|
||||
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type EditTemplateFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
template: Template;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
initialTemplate: TemplateWithDetails;
|
||||
isEnterprise: boolean;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
type EditTemplateStep = 'signers' | 'fields';
|
||||
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
|
||||
type EditTemplateStep = 'settings' | 'signers' | 'fields';
|
||||
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
||||
|
||||
export const EditTemplateForm = ({
|
||||
initialTemplate,
|
||||
className,
|
||||
template,
|
||||
recipients,
|
||||
fields,
|
||||
user: _user,
|
||||
documentData,
|
||||
isEnterprise,
|
||||
templateRootPath,
|
||||
}: EditTemplateFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<EditTemplateStep>('signers');
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [step, setStep] = useState<EditTemplateStep>('settings');
|
||||
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: template, refetch: refetchTemplate } =
|
||||
trpc.template.getTemplateWithDetailsById.useQuery(
|
||||
{
|
||||
id: initialTemplate.id,
|
||||
},
|
||||
{
|
||||
initialData: initialTemplate,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
},
|
||||
);
|
||||
|
||||
const { Recipient: recipients, Field: fields, templateDocumentData } = template;
|
||||
|
||||
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||
settings: {
|
||||
title: 'General',
|
||||
description: 'Configure general settings for the template.',
|
||||
stepIndex: 1,
|
||||
},
|
||||
signers: {
|
||||
title: 'Add Placeholders',
|
||||
description: 'Add all relevant placeholders for each recipient.',
|
||||
stepIndex: 1,
|
||||
stepIndex: 2,
|
||||
},
|
||||
fields: {
|
||||
title: 'Add Fields',
|
||||
description: 'Add all relevant fields for each recipient.',
|
||||
stepIndex: 2,
|
||||
stepIndex: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
|
||||
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
|
||||
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateWithDetailsById.setData(
|
||||
{
|
||||
id: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateWithDetailsById.setData(
|
||||
{
|
||||
id: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateWithDetailsById.setData(
|
||||
{
|
||||
id: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
try {
|
||||
await updateTemplateSettings({
|
||||
templateId: template.id,
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
},
|
||||
meta: data.meta,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('signers');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while updating the document settings.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddTemplatePlaceholderFormSubmit = async (
|
||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||
@ -72,9 +159,11 @@ export const EditTemplateForm = ({
|
||||
try {
|
||||
await addTemplateSigners({
|
||||
templateId: template.id,
|
||||
teamId: team?.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('fields');
|
||||
@ -100,6 +189,9 @@ export const EditTemplateForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
router.push(templateRootPath);
|
||||
} catch (err) {
|
||||
toast({
|
||||
@ -110,6 +202,15 @@ export const EditTemplateForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh the data in the background when steps change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
void refetchTemplate();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
@ -117,7 +218,11 @@ export const EditTemplateForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||
<LazyPDFViewer
|
||||
key={templateDocumentData.id}
|
||||
documentData={templateDocumentData}
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -135,12 +240,25 @@ export const EditTemplateForm = ({
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||
>
|
||||
<AddTemplateSettingsFormPartial
|
||||
key={recipients.length}
|
||||
template={template}
|
||||
documentFlow={documentFlow.settings}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplatePlaceholderRecipientsFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplateFieldsFormPartial
|
||||
|
||||
@ -5,10 +5,9 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
@ -35,7 +34,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateById({
|
||||
const template = await getTemplateWithDetailsById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
@ -44,21 +43,13 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { templateDocumentData } = template;
|
||||
|
||||
const [templateRecipients, templateFields] = await Promise.all([
|
||||
getRecipientsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
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">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
@ -73,13 +64,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-8"
|
||||
template={template}
|
||||
user={user}
|
||||
recipients={templateRecipients}
|
||||
fields={templateFields}
|
||||
documentData={templateDocumentData}
|
||||
className="mt-6"
|
||||
initialTemplate={template}
|
||||
templateRootPath={templateRootPath}
|
||||
isEnterprise={isTemplateEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,48 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FilePlus, X } from 'lucide-react';
|
||||
import { FilePlus, Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { 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 { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZCreateTemplateFormSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
||||
|
||||
type NewTemplateDialogProps = {
|
||||
teamId?: number;
|
||||
templateRootPath: string;
|
||||
@ -54,51 +35,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TCreateTemplateFormSchema>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
},
|
||||
resolver: zodResolver(ZCreateTemplateFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
|
||||
trpc.template.createTemplate.useMutation();
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
||||
|
||||
setUploadedFile({
|
||||
file,
|
||||
fileBase64: `data:application/pdf;base64,${base64String}`,
|
||||
});
|
||||
|
||||
if (!form.getValues('name')) {
|
||||
form.setValue('name', file.name);
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TCreateTemplateFormSchema) => {
|
||||
if (!uploadedFile) {
|
||||
if (isUploadingFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file: File = uploadedFile.file;
|
||||
setIsUploadingFile(true);
|
||||
|
||||
try {
|
||||
const { type, data } = await putFile(file);
|
||||
|
||||
const { type, data } = await putPdfFile(file);
|
||||
const { id: templateDocumentDataId } = await createDocumentData({
|
||||
type,
|
||||
data,
|
||||
@ -106,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
|
||||
const { id } = await createTemplate({
|
||||
teamId,
|
||||
title: values.name ? values.name : file.name,
|
||||
title: file.name,
|
||||
templateDocumentDataId,
|
||||
});
|
||||
|
||||
@ -126,25 +76,16 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
description: 'Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsUploadingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
if (form.getValues('name') === uploadedFile?.file.name) {
|
||||
form.reset();
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNewTemplateDialog) {
|
||||
form.reset();
|
||||
}
|
||||
}, [form, showNewTemplateDialog]);
|
||||
|
||||
return (
|
||||
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
|
||||
<Dialog
|
||||
open={showNewTemplateDialog}
|
||||
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||
@ -154,81 +95,29 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
|
||||
<DialogContent className="w-full max-w-xl">
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name your template</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Leave this empty if you would like to use your document's name for the
|
||||
template
|
||||
</span>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="relative">
|
||||
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||
|
||||
<div>
|
||||
<Label htmlFor="template">Upload a Document</Label>
|
||||
|
||||
<div className="my-3">
|
||||
{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="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">
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||
<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">
|
||||
Uploaded Document
|
||||
</p>
|
||||
|
||||
<span className="text-muted-foreground/80 mt-1 text-sm">
|
||||
{uploadedFile.file.name}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<DocumentDropzone
|
||||
className="mt-1.5 h-[40vh]"
|
||||
onDrop={onFileDrop}
|
||||
type="template"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button loading={isCreatingTemplate} type="submit">
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
{isUploadingFile && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { InfoIcon, Plus } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
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 { RecipientRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@ -19,24 +26,59 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 { Label } from '@documenso/ui/primitives/label';
|
||||
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
sendDocument: z.boolean(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
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>;
|
||||
|
||||
@ -54,35 +96,33 @@ export function UseTemplateDialog({
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||
defaultValues: {
|
||||
recipients:
|
||||
recipients.length > 0
|
||||
? recipients.map((recipient) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
sendDocument: false,
|
||||
recipients: recipients.map((recipient) => {
|
||||
const isRecipientEmailPlaceholder = recipient.email.match(
|
||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||
);
|
||||
|
||||
const isRecipientNamePlaceholder = recipient.name.match(
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
);
|
||||
|
||||
return {
|
||||
id: recipient.id,
|
||||
name: !isRecipientNamePlaceholder ? recipient.name : '',
|
||||
email: !isRecipientEmailPlaceholder ? recipient.email : '',
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
|
||||
const { mutateAsync: createDocumentFromTemplate } =
|
||||
trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
@ -91,6 +131,7 @@ export function UseTemplateDialog({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
recipients: data.recipients,
|
||||
sendDocument: data.sendDocument,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -101,23 +142,35 @@ export function UseTemplateDialog({
|
||||
|
||||
router.push(`${documentRootPath}/${id}`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const toastPayload: Toast = {
|
||||
title: 'Error',
|
||||
description: 'An error occurred while creating document from template.',
|
||||
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({
|
||||
control,
|
||||
control: form.control,
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||
@ -126,121 +179,110 @@ export function UseTemplateDialog({
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Document Recipients</DialogTitle>
|
||||
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
|
||||
<DialogTitle>Create document from template</DialogTitle>
|
||||
<DialogDescription>
|
||||
{recipients.length === 0
|
||||
? 'A draft document will be created'
|
||||
: 'Add the recipients to create the document with'}
|
||||
</DialogDescription>
|
||||
</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
|
||||
control={control}
|
||||
name={`recipients.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`recipient-${recipient.id}-email`}
|
||||
type="email"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||
{formRecipients.map((recipient, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`recipients.${index}.email`}
|
||||
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">
|
||||
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`recipients.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && <FormLabel>Name</FormLabel>}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`recipients.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`recipient-${recipient.id}-name`}
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={recipients[index].name || 'Name'} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-[60px]">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`recipients.${index}.role`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select value={value} onValueChange={(x) => onChange(x)}>
|
||||
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
||||
{recipients.length > 0 && (
|
||||
<div className="mt-4 flex flex-row items-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendDocument"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="sendDocument"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<SelectContent className="" align="end">
|
||||
<SelectItem value={RecipientRole.SIGNER}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
||||
Signer
|
||||
</div>
|
||||
</SelectItem>
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="sendDocument"
|
||||
>
|
||||
Send document
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<SelectItem value={RecipientRole.CC}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
||||
Receives copy
|
||||
</div>
|
||||
</SelectItem>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
The document will be immediately sent to recipients if this is
|
||||
checked.
|
||||
</p>
|
||||
|
||||
<SelectItem value={RecipientRole.APPROVER}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||
Approver
|
||||
</div>
|
||||
</SelectItem>
|
||||
<p>Otherwise, the document will be created as a draft.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SelectItem value={RecipientRole.VIEWER}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||
Viewer
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<div className="w-full">
|
||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
|
||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isCreatingDocumentFromTemplate}
|
||||
disabled={isCreatingDocumentFromTemplate}
|
||||
onClick={onCreateDocumentFromTemplate}
|
||||
>
|
||||
Create Document
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -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 { getServerSession } from 'next-auth';
|
||||
import { env } from 'next-runtime-env';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
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 { SigningAuthPageView } from '../signing-auth-page';
|
||||
import { ClaimAccount } from './claim-account';
|
||||
import { DocumentPreviewButton } from './document-preview-button';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
|
||||
export default async function CompletedSigningPage({
|
||||
params: { token },
|
||||
}: CompletedSigningPageProps) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
|
||||
|
||||
const sessionData = await getServerSession();
|
||||
const isLoggedIn = !!sessionData?.user;
|
||||
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'-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',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<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>
|
||||
))}
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
You have
|
||||
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||
</h2>
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
Document
|
||||
{recipient.role === RecipientRole.SIGNER && ' Signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' Approved '}
|
||||
</h2>
|
||||
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.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>
|
||||
))}
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center mt-4 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>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.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 ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
|
||||
{document.status === DocumentStatus.COMPLETED ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
Go Back Home
|
||||
</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>
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
|
||||
@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
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 [document, fields, recipient] = await Promise.all([
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
@ -45,9 +46,15 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getCompletedFieldsForToken({ token }),
|
||||
]);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
if (
|
||||
!document ||
|
||||
!document.documentData ||
|
||||
!recipient ||
|
||||
document.status === DocumentStatus.DRAFT
|
||||
) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
@ -120,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||
>
|
||||
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
||||
<SigningPageView
|
||||
recipient={recipient}
|
||||
document={document}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</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 { 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 { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
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 { DateField } from './date-field';
|
||||
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
|
||||
document: DocumentAndSender;
|
||||
recipient: Recipient;
|
||||
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 { documentData, documentMeta } = document;
|
||||
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
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 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 { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
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 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 (
|
||||
<div>
|
||||
|
||||
@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
export default function SignatureDisclosure() {
|
||||
return (
|
||||
<div>
|
||||
<article className="prose">
|
||||
<article className="prose dark:prose-invert">
|
||||
<h1>Electronic Signature Disclosure</h1>
|
||||
|
||||
<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 { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: Recipient;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
||||
|
||||
const onRecipientClick = () => {
|
||||
if (!recipient.token) {
|
||||
if (!signingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The signing link has been copied to your clipboard.',
|
||||
@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
title={recipient.token && 'Click to copy signing link for sending to recipient'}
|
||||
role={signingToken ? 'button' : undefined}
|
||||
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||
onClick={onRecipientClick}
|
||||
>
|
||||
<StackAvatar
|
||||
@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title="Click to copy signing link for sending to recipient"
|
||||
>
|
||||
<p>{recipient.email} </p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||
>
|
||||
<p>{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,33 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
recipients: Recipient[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const StackAvatarsWithTooltip = ({
|
||||
documentStatus,
|
||||
recipients,
|
||||
position,
|
||||
children,
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isControlled = useRef(false);
|
||||
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||
);
|
||||
@ -44,105 +39,74 @@ export const StackAvatarsWithTooltip = ({
|
||||
(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 (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
className="flex cursor-pointer"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{children || <StackAvatars recipients={recipients} />}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side={position}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className="flex flex-col gap-y-5 py-2"
|
||||
>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Completed</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<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>
|
||||
<PopoverHover
|
||||
trigger={children || <StackAvatars recipients={recipients} />}
|
||||
contentProps={{
|
||||
className: 'flex flex-col gap-y-5 py-2',
|
||||
side: position,
|
||||
}}
|
||||
>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Completed</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Waiting</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Waiting</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Opened</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Opened</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverHover>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
@ -18,7 +17,6 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
CommandDialog,
|
||||
@ -71,7 +69,6 @@ export type CommandMenuProps = {
|
||||
|
||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const { data: session } = useSession();
|
||||
|
||||
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(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}));
|
||||
}, [searchDocumentsData, isOwner, getSigningLink]);
|
||||
}, [searchDocumentsData]);
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } 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 { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
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 params = useParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
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">
|
||||
<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" />
|
||||
</Link>
|
||||
|
||||
<DesktopNav />
|
||||
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
||||
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<MenuSwitcher user={user} teams={teams} />
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
@ -25,6 +26,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
const MotionLink = motion(Link);
|
||||
|
||||
export type MenuSwitcherProps = {
|
||||
user: User;
|
||||
teams: GetTeamsResponse;
|
||||
@ -93,7 +96,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
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
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
@ -102,12 +105,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
textSectionClassName="hidden lg:flex"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<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"
|
||||
forceMount
|
||||
>
|
||||
@ -169,18 +173,43 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||
<MotionLink
|
||||
initial="initial"
|
||||
animate="initial"
|
||||
whileHover="animate"
|
||||
href={formatRedirectUrlOnSwitch(team.url)}
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
secondaryText={formatSecondaryAvatarText(team)}
|
||||
secondaryText={
|
||||
<div className="relative">
|
||||
<motion.span
|
||||
className="overflow-hidden"
|
||||
variants={{
|
||||
initial: { opacity: 1, translateY: 0 },
|
||||
animate: { opacity: 0, translateY: '100%' },
|
||||
}}
|
||||
>
|
||||
{formatSecondaryAvatarText(team)}
|
||||
</motion.span>
|
||||
|
||||
<motion.span
|
||||
className="absolute inset-0"
|
||||
variants={{
|
||||
initial: { opacity: 0, translateY: '100%' },
|
||||
animate: { opacity: 1, translateY: 0 },
|
||||
}}
|
||||
>{`/t/${team.url}`}</motion.span>
|
||||
</div>
|
||||
}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</MotionLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<Image
|
||||
src={LogoImage}
|
||||
@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
convertToLocalSystemFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { DocumentMeta } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: CompletedField[];
|
||||
documentMeta?: DocumentMeta;
|
||||
};
|
||||
|
||||
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
|
||||
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
||||
|
||||
const handleHideField = (fieldId: string) => {
|
||||
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
||||
};
|
||||
|
||||
return (
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map(
|
||||
(field) =>
|
||||
!hiddenFieldIds[field.secondaryId] && (
|
||||
<FieldRootContainer
|
||||
field={field}
|
||||
key={field.id}
|
||||
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
|
||||
>
|
||||
<div className="absolute -right-3 -top-3">
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
|
||||
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
|
||||
{extractInitials(field.Recipient.name || field.Recipient.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'flex w-fit flex-col py-2.5 text-sm',
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<span className="font-semibold">
|
||||
{field.Recipient.name
|
||||
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||
: field.Recipient.email}{' '}
|
||||
</span>
|
||||
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
|
||||
onClick={() => handleHideField(field.secondaryId)}
|
||||
>
|
||||
Hide field
|
||||
</Button>
|
||||
</PopoverHover>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground break-all text-sm">
|
||||
{match(field)
|
||||
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||
field.Signature?.signatureImageAsBase64 ? (
|
||||
<img
|
||||
src={field.Signature.signatureImageAsBase64}
|
||||
alt="Signature"
|
||||
className="h-full w-full object-contain dark:invert"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{field.Signature?.typedSignature}
|
||||
</p>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
|
||||
() => field.customText,
|
||||
)
|
||||
.with({ type: FieldType.DATE }, () =>
|
||||
convertToLocalSystemFormat(
|
||||
field.customText,
|
||||
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
),
|
||||
)
|
||||
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</FieldRootContainer>
|
||||
),
|
||||
)}
|
||||
</ElementVisible>
|
||||
);
|
||||
};
|
||||
@ -28,7 +28,7 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisable2FAForm = z.object({
|
||||
@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -30,7 +30,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -30,7 +30,7 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
@ -372,9 +373,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Token</FormLabel>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
error: '/signin',
|
||||
},
|
||||
events: {
|
||||
signIn: async ({ user }) => {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN,
|
||||
},
|
||||
});
|
||||
signIn: async ({ user: { id: userId } }) => {
|
||||
const [user] = await Promise.all([
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
}),
|
||||
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 }) => {
|
||||
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';
|
||||
|
||||
export const config = {
|
||||
maxDuration: 60,
|
||||
maxDuration: 120,
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '50mb',
|
||||
|
||||
@ -41,7 +41,7 @@ volumes:
|
||||
1. Run the following command to start the containers:
|
||||
|
||||
```
|
||||
docker-compose --env-file ./.env -d up
|
||||
docker-compose --env-file ./.env up -d
|
||||
```
|
||||
|
||||
This will start the PostgreSQL database and the Documenso application containers.
|
||||
|
||||
@ -58,7 +58,7 @@ services:
|
||||
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
|
||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?:-/opt/documenso/cert.p12}
|
||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
||||
ports:
|
||||
- ${PORT:-3000}:${PORT:-3000}
|
||||
volumes:
|
||||
|
||||
146
package-lock.json
generated
146
package-lock.json
generated
@ -22,7 +22,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@ -109,6 +109,7 @@
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
@ -4716,12 +4717,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
||||
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==",
|
||||
"version": "1.43.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
|
||||
"integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.40.0"
|
||||
"playwright": "1.43.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@ -4745,12 +4746,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"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.40.0"
|
||||
"playwright-core": "1.43.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@ -4763,9 +4764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"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"
|
||||
@ -13767,6 +13768,15 @@
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz",
|
||||
"integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
|
||||
@ -17536,6 +17546,7 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
|
||||
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -17580,18 +17591,15 @@
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "3.6.172",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.6.172.tgz",
|
||||
"integrity": "sha512-bfOhCg+S9DXh/ImWhWYTOiq3aVMFSCvzGiBzsIJtdMC71kVWDBw7UXr32xh0y56qc5wMVylIeqV3hBaRsu+e+w==",
|
||||
"dependencies": {
|
||||
"path2d-polyfill": "^2.0.1",
|
||||
"web-streams-polyfill": "^3.2.1"
|
||||
},
|
||||
"version": "3.11.174",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvas": "^2.11.2"
|
||||
"canvas": "^2.11.2",
|
||||
"path2d-polyfill": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
@ -19011,42 +19019,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"node_modules/react-pdf": {
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.3.3.tgz",
|
||||
"integrity": "sha512-d7WAxcsjOogJfJ+I+zX/mdip3VjR1yq/yDa4hax4XbQVjbbbup6rqs4c8MGx0MLSnzob17TKp1t4CsNbDZ6GeQ==",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"make-cancellable-promise": "^1.3.1",
|
||||
"make-event-props": "^1.6.0",
|
||||
"merge-refs": "^1.2.1",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"prop-types": "^15.6.2",
|
||||
"tiny-invariant": "^1.0.0",
|
||||
"tiny-warning": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-pdf/node_modules/clsx": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
|
||||
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-property": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz",
|
||||
@ -21357,11 +21329,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
|
||||
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz",
|
||||
@ -22986,6 +22953,14 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
||||
@ -24981,7 +24956,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@ -24989,7 +24964,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "^1.43.0",
|
||||
"@playwright/browser-chromium": "1.43.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
},
|
||||
@ -25390,11 +25365,13 @@
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.2",
|
||||
"next": "14.0.3",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"react": "18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "7.3.3",
|
||||
"react-pdf": "7.7.3",
|
||||
"react-rnd": "^10.4.1",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
@ -25411,6 +25388,43 @@
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
},
|
||||
"packages/ui/node_modules/react-pdf": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.7.3.tgz",
|
||||
"integrity": "sha512-a2VfDl8hiGjugpqezBTUzJHYLNB7IS7a2t7GD52xMI9xHg8LdVaTMsnM9ZlNmKadnStT/tvX5IfV0yLn+JvYmw==",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"dequal": "^2.0.3",
|
||||
"make-cancellable-promise": "^1.3.1",
|
||||
"make-event-props": "^1.6.0",
|
||||
"merge-refs": "^1.2.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"prop-types": "^15.6.2",
|
||||
"tiny-invariant": "^1.0.0",
|
||||
"warning": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/ui/node_modules/react-pdf/node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"packages/ui/node_modules/typescript": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
|
||||
@ -11,6 +11,9 @@ import {
|
||||
ZDeleteDocumentMutationSchema,
|
||||
ZDeleteFieldMutationSchema,
|
||||
ZDeleteRecipientMutationSchema,
|
||||
ZDownloadDocumentSuccessfulSchema,
|
||||
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||
ZGenerateDocumentFromTemplateMutationSchema,
|
||||
ZGetDocumentsQuerySchema,
|
||||
ZSendDocumentForSigningMutationSchema,
|
||||
ZSuccessfulDocumentResponseSchema,
|
||||
@ -51,6 +54,17 @@ export const ApiContractV1 = c.router(
|
||||
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: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents',
|
||||
@ -73,6 +87,24 @@ export const ApiContractV1 = c.router(
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a new document from an existing template',
|
||||
deprecated: true,
|
||||
description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`,
|
||||
},
|
||||
|
||||
generateDocumentFromTemplate: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/templates/:templateId/generate-document',
|
||||
body: ZGenerateDocumentFromTemplateMutationSchema,
|
||||
responses: {
|
||||
200: ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a new document from an existing template',
|
||||
description:
|
||||
'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.',
|
||||
},
|
||||
|
||||
sendDocument: {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { createNextRoute } from '@ts-rest/next';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||
@ -19,11 +21,16 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
|
||||
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 { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
||||
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import {
|
||||
getPresignGetUrl,
|
||||
getPresignPostUrl,
|
||||
} from '@documenso/lib/universal/upload/server-actions';
|
||||
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
@ -70,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
status: 200,
|
||||
body: {
|
||||
...document,
|
||||
recipients,
|
||||
recipients: recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@ -83,6 +93,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) => {
|
||||
const { id: documentId } = args.params;
|
||||
|
||||
@ -164,6 +236,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
await upsertDocumentMeta({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
...body.meta,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
const recipients = await setRecipientsForDocument({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
@ -183,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@ -214,7 +295,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
|
||||
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||
|
||||
const document = await createDocumentFromTemplate({
|
||||
const document = await createDocumentFromTemplateLegacy({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
@ -231,7 +312,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
formValues: body.formValues,
|
||||
});
|
||||
|
||||
const newDocumentData = await putFile({
|
||||
const newDocumentData = await putPdfFile({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
@ -259,10 +340,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
await upsertDocumentMeta({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
subject: body.meta.subject,
|
||||
message: body.meta.message,
|
||||
dateFormat: body.meta.dateFormat,
|
||||
timezone: body.meta.timezone,
|
||||
...body.meta,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
}
|
||||
@ -277,6 +355,89 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { body, params } = args;
|
||||
|
||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'You have reached the maximum number of documents allowed for this month',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const templateId = Number(params.templateId);
|
||||
|
||||
let document: CreateDocumentFromTemplateResponse | null = null;
|
||||
|
||||
try {
|
||||
document = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
recipients: body.recipients,
|
||||
override: {
|
||||
title: body.title,
|
||||
...body.meta,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return AppError.toRestAPIError(err);
|
||||
}
|
||||
|
||||
if (body.formValues) {
|
||||
const fileName = document.title.endsWith('.pdf') ? document.title : `${document.title}.pdf`;
|
||||
|
||||
const pdf = await getFile(document.documentData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(pdf),
|
||||
formValues: body.formValues,
|
||||
});
|
||||
|
||||
const newDocumentData = await putPdfFile({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
await updateDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
formValues: body.formValues,
|
||||
documentData: {
|
||||
connect: {
|
||||
id: newDocumentData.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
documentId: document.id,
|
||||
recipients: document.Recipient.map((recipient) => ({
|
||||
recipientId: recipient.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@ -284,6 +445,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
|
||||
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id } = args.params;
|
||||
const { sendEmail = true } = args.body ?? {};
|
||||
|
||||
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
||||
|
||||
@ -339,10 +501,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
// });
|
||||
// }
|
||||
|
||||
await sendDocument({
|
||||
const { Recipient: recipients, ...sentDocument } = await sendDocument({
|
||||
documentId: Number(id),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
sendEmail,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
@ -350,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
status: 200,
|
||||
body: {
|
||||
message: 'Document sent for signing successfully',
|
||||
...sentDocument,
|
||||
recipients: recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@ -434,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
body: {
|
||||
...newRecipient,
|
||||
documentId: Number(documentId),
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@ -499,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
body: {
|
||||
...updatedRecipient,
|
||||
documentId: Number(documentId),
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@ -552,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
body: {
|
||||
...deletedRecipient,
|
||||
documentId: Number(documentId),
|
||||
signingUrl: '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
|
||||
export const OpenAPIV1 = generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
export const OpenAPIV1 = Object.assign(
|
||||
generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZUrlSchema } from '@documenso/lib/schemas/common';
|
||||
import {
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
@ -44,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
||||
|
||||
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
||||
|
||||
export const ZSendDocumentForSigningMutationSchema = null;
|
||||
export const ZSendDocumentForSigningMutationSchema = z
|
||||
.object({
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
})
|
||||
.or(z.literal('').transform(() => ({ sendEmail: true })));
|
||||
|
||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||
|
||||
@ -53,6 +58,10 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
||||
downloadUrl: z.string(),
|
||||
});
|
||||
|
||||
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
|
||||
|
||||
export const ZCreateDocumentMutationSchema = z.object({
|
||||
@ -84,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string().email().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
|
||||
signingUrl: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@ -129,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||
|
||||
signingUrl: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@ -137,6 +152,61 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
typeof ZCreateDocumentFromTemplateMutationResponseSchema
|
||||
>;
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().min(1),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.map((signer) => signer.email.toLowerCase());
|
||||
const ids = schema.map((signer) => signer.id);
|
||||
|
||||
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
||||
},
|
||||
{ message: 'Recipient IDs and emails must be unique' },
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
timezone: z.string(),
|
||||
dateFormat: z.string(),
|
||||
redirectUrl: ZUrlSchema,
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
});
|
||||
|
||||
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||
typeof ZGenerateDocumentFromTemplateMutationSchema
|
||||
>;
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string().email().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
|
||||
signingUrl: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
typeof ZGenerateDocumentFromTemplateMutationResponseSchema
|
||||
>;
|
||||
|
||||
export const ZCreateRecipientMutationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
@ -171,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
||||
readStatus: z.nativeEnum(ReadStatus),
|
||||
signingStatus: z.nativeEnum(SigningStatus),
|
||||
sendStatus: z.nativeEnum(SendStatus),
|
||||
|
||||
signingUrl: z.string(),
|
||||
});
|
||||
|
||||
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||
@ -221,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
|
||||
|
||||
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||
|
||||
export const ZSuccessfulSigningResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
export const ZSuccessfulSigningResponseSchema = z
|
||||
.object({
|
||||
message: z.string(),
|
||||
})
|
||||
.and(ZSuccessfulGetDocumentResponseSchema);
|
||||
|
||||
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||
|
||||
|
||||
@ -41,8 +41,8 @@ test.describe('[EE_ONLY]', () => {
|
||||
|
||||
// Set EE action auth.
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@ -52,11 +52,7 @@ test.describe('[EE_ONLY]', () => {
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
// Todo: Verify that the values are correct once we fix the issue where going back
|
||||
// does not show the updated values.
|
||||
// await expect(page.getByLabel('Title')).toContainText('New Title');
|
||||
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
@ -89,8 +85,8 @@ test.describe('[EE_ONLY]', () => {
|
||||
|
||||
// Set EE action auth.
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@ -168,11 +164,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
// Todo: Verify that the values are correct once we fix the issue where going back
|
||||
// does not show the updated values.
|
||||
// await expect(page.getByLabel('Title')).toContainText('New Title');
|
||||
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
||||
await expect(page.getByLabel('Title')).toHaveValue('New Title');
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
@ -45,10 +45,10 @@ test.describe('[EE_ONLY]', () => {
|
||||
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');
|
||||
|
||||
// Display advanced settings.
|
||||
await page.getByLabel('Show advanced settings').click();
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
|
||||
// Navigate to the next step and back.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@ -62,7 +62,6 @@ test.describe('[EE_ONLY]', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Not complete yet due to issue with back button.
|
||||
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
@ -82,7 +81,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
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: '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.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
@ -93,26 +92,5 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Todo: Fix stepper component back issue before finishing test.
|
||||
|
||||
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
|
||||
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
|
||||
|
||||
// // Add advanced settings for a single recipient.
|
||||
// await page.getByLabel('Show advanced settings').click();
|
||||
// await page.getByRole('combobox').first().click();
|
||||
// await page.getByLabel('Require account').click();
|
||||
|
||||
// // Navigate to the next step and back.
|
||||
// await page.getByRole('button', { name: 'Continue' }).click();
|
||||
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
// await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
|
||||
// settings were applied.
|
||||
|
||||
// Todo: Fix stepper component back issue before finishing test.
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
@ -136,7 +140,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByPlaceholder('Name').fill('User 1');
|
||||
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: '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();
|
||||
|
||||
@ -192,6 +196,102 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
await page.getByLabel('Title').fill('Test Title');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('user1@example.com');
|
||||
await page.getByPlaceholder('Name').fill('User 1');
|
||||
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: 'Name', exact: true }).nth(1).fill('User 2');
|
||||
await page.locator('button[role="combobox"]').nth(1).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(1).fill('user3@example.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(2).fill('User 3');
|
||||
await page.locator('button[role="combobox"]').nth(2).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(2).fill('user4@example.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(3).fill('User 4');
|
||||
await page.locator('button[role="combobox"]').nth(3).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'User 1 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByText('User 1 (user1@example.com)').click();
|
||||
await page.getByText('User 3 (user3@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'User 3 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
@ -234,6 +334,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await page.getByRole('link', { name: documentTitle }).click();
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Start signing process
|
||||
const url = page.url().split('/');
|
||||
const documentId = url[url.length - 1];
|
||||
|
||||
@ -254,7 +355,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
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
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
@ -263,6 +364,63 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['user@documenso.com', 'approver@documenso.com'],
|
||||
recipientsCreateOptions: [
|
||||
{
|
||||
email: 'user@documenso.com',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
{
|
||||
email: 'approver@documenso.com',
|
||||
role: RecipientRole.APPROVER,
|
||||
},
|
||||
],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const { token, Field, role } = recipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: role === RecipientRole.SIGNER ? 'Sign Document' : 'Approve Document',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
||||
.click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
}
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
|
||||
page,
|
||||
}) => {
|
||||
@ -333,3 +491,46 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const customDate = DateTime.local().toFormat('yyyy-MM-dd hh:mm a');
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['user1@example.com'],
|
||||
fields: [FieldType.DATE],
|
||||
});
|
||||
|
||||
const { token, Field } = recipients[0];
|
||||
const [recipientField] = Field;
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
Recipient: {
|
||||
email: 'user1@example.com',
|
||||
},
|
||||
documentId: Number(document.id),
|
||||
},
|
||||
});
|
||||
|
||||
expect(field?.customText).toBe(customDate);
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByRole('cell', { name: 'Download' })
|
||||
@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
// Open document action menu.
|
||||
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||
|
||||
// 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 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 });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => {
|
||||
const { sender } = await seedDeleteDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
|
||||
email: sender.email,
|
||||
});
|
||||
|
||||
// open actions menu
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||
.getByRole('cell', { name: 'Edit' })
|
||||
.getByRole('button')
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
// 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 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 { 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 { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
|
||||
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 }) => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
|
||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('[EE_ONLY]', () => {
|
||||
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
||||
|
||||
test.beforeEach(() => {
|
||||
test.skip(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
|
||||
'Billing required for this test',
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW] add action auth settings', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: user.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Set EE action auth.
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Return to the settings step to check that the results are saved correctly.
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Make the team enterprise by giving the owner the enterprise subscription.
|
||||
await seedUserSubscription({
|
||||
userId: team.ownerUserId,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
const template = await seedBlankTemplate(owner, {
|
||||
createTemplateOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamMemberUser.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Set EE action auth.
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Advanced settings should be visible.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Make the team enterprise by giving the owner the enterprise subscription.
|
||||
await seedUserSubscription({
|
||||
userId: team.ownerUserId,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
const template = await seedBlankTemplate(teamMemberUser);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamMemberUser.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Global action auth should not be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
|
||||
// Next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Advanced settings should not be visible.
|
||||
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Set title.
|
||||
await page.getByLabel('Title').fill('New Title');
|
||||
|
||||
// Set access auth.
|
||||
await page.getByTestId('documentAccessSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Action auth should NOT be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Return to the settings step to check that the results are saved correctly.
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Title')).toHaveValue('New Title');
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('[EE_ONLY]', () => {
|
||||
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
||||
|
||||
test.beforeEach(() => {
|
||||
test.skip(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
|
||||
'Billing required for this test',
|
||||
);
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: user.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Email', exact: true })
|
||||
.fill('recipient2@documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||
|
||||
// Display advanced settings.
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
|
||||
// Navigate to the next step and back.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Expect that the advanced settings is unchecked, since no advanced settings were applied.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
|
||||
|
||||
// Add advanced settings for a single recipient.
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByLabel('Require passkey').click();
|
||||
|
||||
// Navigate to the next step and back.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
|
||||
// settings were applied.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
@ -0,0 +1,285 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
||||
|
||||
/**
|
||||
* 1. Create a template with all settings filled out
|
||||
* 2. Create a document from the template
|
||||
* 3. Ensure all values are correct
|
||||
*
|
||||
* Note: There is a direct copy paste of this test below for teams.
|
||||
*
|
||||
* If you update this test please update that test as well.
|
||||
*/
|
||||
test('[TEMPLATE]: should create a document from a template', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
const isBillingEnabled =
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: user.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
|
||||
|
||||
// Set template document access.
|
||||
await page.getByTestId('documentAccessSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Set EE action auth.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
}
|
||||
|
||||
// Set email options.
|
||||
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||
|
||||
// Apply require passkey for Recipient 1.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByLabel('Require passkey').click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL('/templates');
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const documentAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||
isBillingEnabled ? 'PASSKEY' : null,
|
||||
);
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||
|
||||
const recipientOne = document.Recipient[0];
|
||||
const recipientTwo = document.Recipient[1];
|
||||
|
||||
const recipientOneAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientOne.authOptions,
|
||||
});
|
||||
|
||||
const recipientTwoAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientTwo.authOptions,
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||
}
|
||||
|
||||
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
});
|
||||
|
||||
/**
|
||||
* This is a direct copy paste of the above test but for teams.
|
||||
*/
|
||||
test('[TEMPLATE]: should create a team document from a team template', async ({ page }) => {
|
||||
const { owner, ...team } = await seedTeam({
|
||||
createTeamMembers: 2,
|
||||
});
|
||||
|
||||
const template = await seedBlankTemplate(owner, {
|
||||
createTemplateOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const isBillingEnabled =
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: owner.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
|
||||
|
||||
// Set template document access.
|
||||
await page.getByTestId('documentAccessSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Set EE action auth.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
}
|
||||
|
||||
// Set email options.
|
||||
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||
|
||||
// Apply require passkey for Recipient 1.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByLabel('Require passkey').click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL(`/t/${team.url}/templates`);
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.teamId).toEqual(team.id);
|
||||
|
||||
const documentAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||
isBillingEnabled ? 'PASSKEY' : null,
|
||||
);
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||
|
||||
const recipientOne = document.Recipient[0];
|
||||
const recipientTwo = document.Recipient[1];
|
||||
|
||||
const recipientOneAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientOne.authOptions,
|
||||
});
|
||||
|
||||
const recipientTwoAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientTwo.authOptions,
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||
}
|
||||
|
||||
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
});
|
||||
@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
|
||||
// Use personal template.
|
||||
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.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||
await page.waitForURL('/documents');
|
||||
@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
|
||||
// Use team template.
|
||||
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.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||
await page.waitForURL(`/t/${team.url}/documents`);
|
||||
|
||||
BIN
packages/assets/fonts/noto-sans.ttf
Normal file
BIN
packages/assets/fonts/noto-sans.ttf
Normal file
Binary file not shown.
@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
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 {
|
||||
DocumentStatus,
|
||||
@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
|
||||
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
|
||||
).then(async (res) => res.arrayBuffer());
|
||||
|
||||
const { id: documentDataId } = await putFile({
|
||||
const { id: documentDataId } = await putPdfFile({
|
||||
name: 'Documenso Supporter Pledge.pdf',
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(documentBuffer),
|
||||
|
||||
@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
|
||||
<br />"{documentName}"
|
||||
</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">
|
||||
You don't need to sign it anymore.
|
||||
</Text>
|
||||
|
||||
@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps {
|
||||
signDocumentLink: string;
|
||||
assetBaseUrl: string;
|
||||
role: RecipientRole;
|
||||
selfSigner: boolean;
|
||||
}
|
||||
|
||||
export const TemplateDocumentInvite = ({
|
||||
@ -19,6 +20,7 @@ export const TemplateDocumentInvite = ({
|
||||
signDocumentLink,
|
||||
assetBaseUrl,
|
||||
role,
|
||||
selfSigner,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
@ -28,8 +30,19 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
{selfSigner ? (
|
||||
<>
|
||||
{`Please ${actionVerb.toLowerCase()} your document`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{`${inviterName} has invited you to ${actionVerb.toLowerCase()}`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<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> & {
|
||||
customBody?: string;
|
||||
role: RecipientRole;
|
||||
selfSigner?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
@ -32,10 +33,13 @@ export const DocumentInviteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
role,
|
||||
selfSigner = false,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
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) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
signDocumentLink={signDocumentLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
role={role}
|
||||
selfSigner={selfSigner}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
* Does not take any person or group properties into account.
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_allow_encrypted_documents: false,
|
||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||
app_document_page_view_history_sheet: false,
|
||||
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { APP_BASE_URL } from './app';
|
||||
|
||||
export const DEFAULT_STANDARD_FONT_SIZE = 15;
|
||||
export const DEFAULT_STANDARD_FONT_SIZE = 12;
|
||||
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||
|
||||
export const MIN_STANDARD_FONT_SIZE = 8;
|
||||
|
||||
2
packages/lib/constants/template.ts
Normal file
2
packages/lib/constants/template.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
|
||||
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
|
||||
@ -1,4 +1,5 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
@ -149,4 +150,24 @@ export class AppError extends Error {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static toRestAPIError(err: unknown): {
|
||||
status: 400 | 401 | 404 | 500;
|
||||
body: { message: string };
|
||||
} {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const status = match(error.code)
|
||||
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
|
||||
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
|
||||
.otherwise(() => 500 as const);
|
||||
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
message: status !== 500 ? error.message : 'Something went wrong',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"playwright": "1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@ -48,6 +48,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@playwright/browser-chromium": "^1.43.0"
|
||||
"@playwright/browser-chromium": "1.43.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user