mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
167 Commits
v1.5.4-rc.
...
feat/integ
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f22fd48d1 | |||
| cc43139573 | |||
| 0c2306b745 | |||
| d11a68fc4c | |||
| c346a3fd6a | |||
| 70eeb1a746 | |||
| d8d0734680 | |||
| 6bd2f68014 | |||
| ede6eea88d | |||
| ebc547684a | |||
| 5724e73d49 | |||
| 4a6b5ceaf8 | |||
| ab949afbb6 | |||
| 3d81b15d71 | |||
| 27fe8c7f8f | |||
| 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 | |||
| f363dee761 | |||
| 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 | |||
| 788c6269a2 | |||
| bd4a1c4c09 | |||
| e0440fd8a2 | |||
| 80c758fb62 | |||
| 7705dbae0c | |||
| 8b58f10cbe | |||
| fe1f0e6a76 | |||
| a82975fd78 | |||
| a4967f19e8 | |||
| a311869c9b | |||
| 732827f81d | |||
| f7ae3104ea | |||
| 02921e53de | |||
| 60c26a9f75 | |||
| 7f7e7da3af | |||
| 82792864de | |||
| 409d8aa5a2 | |||
| f520e0a7a6 | |||
| 462e1348a8 | |||
| 6b73899ecc | |||
| fdbac9fc03 | |||
| 5e8d93f24b | |||
| 870de02efa | |||
| a58a117056 | |||
| 918e9ddc0b | |||
| 94eee8b913 | |||
| 345c4b8b14 | |||
| 897f0dabde | |||
| d5867ae8de | |||
| 5391dd91b0 | |||
| 4855882ae6 | |||
| c08768a330 | |||
| 37e9db6626 |
@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
||||
|
||||
# [[URLS]]
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||
@ -75,7 +79,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': {
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-icons": "^5.2.1",
|
||||
"recharts": "^2.7.2",
|
||||
"sharp": "^0.33.1",
|
||||
"typescript": "5.2.2",
|
||||
|
||||
2
apps/marketing/public/pdf.worker.min.js
vendored
2
apps/marketing/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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';
|
||||
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
|
||||
|
||||
export type MonthlyCompletedDocumentsChartProps = {
|
||||
className?: string;
|
||||
data: GetUserMonthlyGrowthResult;
|
||||
data: GetCompletedDocumentsMonthlyResult;
|
||||
};
|
||||
|
||||
export const MonthlyCompletedDocumentsChart = ({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
|
||||
@ -3,11 +3,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
import type { GetCompletedDocumentsMonthlyResult } from '@documenso/lib/server-only/user/get-monthly-completed-document';
|
||||
|
||||
export type TotalSignedDocumentsChartProps = {
|
||||
className?: string;
|
||||
data: GetUserMonthlyGrowthResult;
|
||||
data: GetCompletedDocumentsMonthlyResult;
|
||||
};
|
||||
|
||||
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
|
||||
import { FileText, Hexagon, Shapes } from 'lucide-react';
|
||||
import { LiaGoogleDrive } from 'react-icons/lia';
|
||||
import { PiMicrosoftTeamsLogo } from 'react-icons/pi';
|
||||
import { TbBrandAirtable, TbBrandZapier } from 'react-icons/tb';
|
||||
|
||||
import { AnimatedDataFlow } from '@documenso/ui/components/animate/animated-data-flow';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const Circle = forwardRef<HTMLDivElement, { className?: string; children?: React.ReactNode }>(
|
||||
({ className, children }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-border z-10 flex h-12 w-12 items-center justify-center rounded-full border-2 bg-white p-3 shadow-[0_0_20px_-12px_rgba(0,0,0,0.8)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function DocumensoIntegrationsDataFlow() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const div1Ref = useRef<HTMLDivElement>(null);
|
||||
const div2Ref = useRef<HTMLDivElement>(null);
|
||||
const div3Ref = useRef<HTMLDivElement>(null);
|
||||
const div4Ref = useRef<HTMLDivElement>(null);
|
||||
const div5Ref = useRef<HTMLDivElement>(null);
|
||||
const div6Ref = useRef<HTMLDivElement>(null);
|
||||
const div7Ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-background relative z-[-2] flex h-full w-full max-w-[32rem] items-center justify-center overflow-hidden p-10"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="flex h-full w-full flex-row items-stretch justify-between gap-10">
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<Circle className="bg-background" ref={div1Ref}>
|
||||
<Shapes color="#A2E771" className="h-6 w-6" />
|
||||
</Circle>
|
||||
<Circle className="bg-background" ref={div2Ref}>
|
||||
<Hexagon color="#A2E771" className="h-6 w-6" />
|
||||
</Circle>
|
||||
<Circle className="bg-background" ref={div3Ref}>
|
||||
<FileText color="#A2E771" className="h-6 w-6" />
|
||||
</Circle>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<Circle ref={div4Ref} className="bg-background h-16 w-16">
|
||||
<TbBrandZapier color="#FF4A00" className="h-6 w-6" />
|
||||
</Circle>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<Circle className="bg-background" ref={div5Ref}>
|
||||
<LiaGoogleDrive color="#A2E771" className="h-6 w-6" />
|
||||
</Circle>
|
||||
<Circle className="bg-background" ref={div6Ref}>
|
||||
<PiMicrosoftTeamsLogo color="#A2E771" className="h-6 w-6" />
|
||||
</Circle>
|
||||
<Circle className="bg-background" ref={div7Ref}>
|
||||
<TbBrandAirtable color="#A2E771" className="h-6 w-6" />
|
||||
</Circle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatedDataFlow containerRef={containerRef} fromRef={div1Ref} toRef={div4Ref} />
|
||||
<AnimatedDataFlow containerRef={containerRef} fromRef={div2Ref} toRef={div4Ref} />
|
||||
<AnimatedDataFlow containerRef={containerRef} fromRef={div3Ref} toRef={div4Ref} />
|
||||
<AnimatedDataFlow containerRef={containerRef} fromRef={div4Ref} toRef={div5Ref} />
|
||||
<AnimatedDataFlow containerRef={containerRef} fromRef={div4Ref} toRef={div6Ref} />
|
||||
<AnimatedDataFlow containerRef={containerRef} fromRef={div4Ref} toRef={div7Ref} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
|
||||
@ -3,13 +3,14 @@ import type { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import cardConnectionsFigure from '@documenso/assets/images/card-connections-figure.png';
|
||||
import cardPaidFigure from '@documenso/assets/images/card-paid-figure.png';
|
||||
import cardSharingFigure from '@documenso/assets/images/card-sharing-figure.png';
|
||||
import cardWidgetFigure from '@documenso/assets/images/card-widget-figure.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { DocumensoIntegrationsDataFlow } from './integrations-data-flow';
|
||||
|
||||
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ShareConnectPaidWidgetBento = ({
|
||||
@ -56,12 +57,9 @@ export const ShareConnectPaidWidgetBento = ({
|
||||
favorite tools.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Image
|
||||
src={cardConnectionsFigure}
|
||||
alt="its fast"
|
||||
className="w-full max-w-sm dark:contrast-[70%] dark:hue-rotate-180 dark:invert"
|
||||
/>
|
||||
<div className="flex w-full items-center justify-center p-0 md:p-8">
|
||||
{/* Add Animated Beam */}
|
||||
<DocumensoIntegrationsDataFlow />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -70,7 +68,7 @@ export const ShareConnectPaidWidgetBento = ({
|
||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||
<p className="text-foreground/80 leading-relaxed">
|
||||
<strong className="block">Get paid (Soon).</strong>
|
||||
Integrated payments with stripe so you don’t have to worry about getting paid.
|
||||
Integrated payments with Stripe so you don’t have to worry about getting paid.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
||||
@ -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'>;
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
@ -104,6 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: 'Documenso Supporter Pledge.pdf',
|
||||
status: DocumentStatus.COMPLETED,
|
||||
userId: user.id,
|
||||
|
||||
@ -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",
|
||||
@ -45,7 +46,7 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-rnd": "^10.4.1",
|
||||
"remeda": "^1.27.1",
|
||||
"sharp": "^0.33.1",
|
||||
|
||||
4
apps/web/process-env.d.ts
vendored
4
apps/web/process-env.d.ts
vendored
@ -12,5 +12,9 @@ declare namespace NodeJS {
|
||||
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
|
||||
}
|
||||
}
|
||||
|
||||
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,26 @@ 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}
|
||||
templateDirectLink={template.directLink}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddTemplateFieldsFormPartial
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
|
||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-3"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setTemplateDirectLinkOpen(true);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{template.directLink ? 'Manage' : 'Create'} Direct Link
|
||||
</Button>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
template={template}
|
||||
open={isTemplateDirectLinkOpen}
|
||||
onOpenChange={setTemplateDirectLinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -5,16 +5,17 @@ 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';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
params: {
|
||||
@ -35,7 +36,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,42 +45,47 @@ 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">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
|
||||
|
||||
{template.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={template.directLink.token}
|
||||
enabled={template.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -4,10 +4,10 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import type { Template } from '@documenso/prisma/client';
|
||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -18,9 +18,10 @@ import {
|
||||
|
||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Template;
|
||||
row: FindTemplateRow;
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
@ -33,6 +34,7 @@ export const DataTableActionDropdown = ({
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
@ -66,6 +68,11 @@ export const DataTableActionDropdown = ({
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
Direct link
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
@ -82,6 +89,12 @@ export const DataTableActionDropdown = ({
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
template={row}
|
||||
open={isTemplateDirectLinkDialogOpen}
|
||||
onOpenChange={setTemplateDirectLinkDialogOpen}
|
||||
/>
|
||||
|
||||
<DeleteTemplateDialog
|
||||
id={row.id}
|
||||
open={isDeleteDialogOpen}
|
||||
|
||||
@ -4,32 +4,26 @@ import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AlertTriangle, Loader } from 'lucide-react';
|
||||
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
import { DataTableTitle } from './data-table-title';
|
||||
import { TemplateDirectLinkBadge } from './template-direct-link-badge';
|
||||
import { UseTemplateDialog } from './use-template-dialog';
|
||||
|
||||
type TemplateWithRecipient = Template & {
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
|
||||
type TemplatesDataTableProps = {
|
||||
templates: Array<
|
||||
TemplateWithRecipient & {
|
||||
team: { id: number; url: string } | null;
|
||||
}
|
||||
>;
|
||||
templates: FindTemplateRow[];
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
@ -48,6 +42,7 @@ export const TemplatesDataTable = ({
|
||||
teamId,
|
||||
}: TemplatesDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const { remaining } = useLimits();
|
||||
@ -88,9 +83,70 @@ export const TemplatesDataTable = ({
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
header: () => (
|
||||
<div className="flex flex-row items-center">
|
||||
Type
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
|
||||
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
|
||||
Public
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Public templates are connected to your public profile. Any modifications
|
||||
to public templates will also appear in your public profile.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
|
||||
<Link2Icon className="mr-1 h-3 w-3" />
|
||||
direct link
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Direct link templates contain one dynamic recipient placeholder. Anyone
|
||||
with access to this link can sign the document, and it will then appear on
|
||||
your documents page.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
|
||||
{teamId ? 'Team Only' : 'Private'}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{teamId
|
||||
? 'Team only templates are not linked anywhere and are visible only to your team.'
|
||||
: 'Private templates can only be modified and viewed by you.'}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => <TemplateType type={row.original.type} />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type="PRIVATE" />
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-2"
|
||||
token={row.original.directLink.token}
|
||||
enabled={row.original.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Link2Icon } from 'lucide-react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDirectLinkBadgeProps = {
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkBadge = ({
|
||||
token,
|
||||
enabled,
|
||||
className,
|
||||
}: TemplateDirectLinkBadgeProps) => {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const { toast } = useToast();
|
||||
|
||||
const onCopyClick = async (token: string) =>
|
||||
copy(formatDirectTemplatePath(token)).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The direct link has been copied to your clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
title="Copy direct link"
|
||||
className={cn(
|
||||
'flex flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600',
|
||||
className,
|
||||
)}
|
||||
onClick={async () => onCopyClick(token)}
|
||||
>
|
||||
<Link2Icon className="mr-1 h-3 w-3" />
|
||||
direct link {!enabled && 'disabled'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,448 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
DIRECT_TEMPLATE_DOCUMENTATION,
|
||||
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import {
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
type Template,
|
||||
type TemplateDirectLink,
|
||||
} from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDirectLinkDialogProps = {
|
||||
template: Template & {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||
|
||||
export const TemplateDirectLinkDialog = ({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TemplateDirectLinkDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { quota, remaining } = useLimits();
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const router = useRouter();
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
||||
const [token, setToken] = useState(template.directLink?.token ?? null);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
||||
token ? 'MANAGE' : 'ONBOARD',
|
||||
);
|
||||
|
||||
const validDirectTemplateRecipients = useMemo(
|
||||
() => template.Recipient.filter((recipient) => recipient.role !== RecipientRole.CC),
|
||||
[template.Recipient],
|
||||
);
|
||||
|
||||
const {
|
||||
mutateAsync: createTemplateDirectLink,
|
||||
isLoading: isCreatingTemplateDirectLink,
|
||||
reset: resetCreateTemplateDirectLink,
|
||||
} = trpcReact.template.createTemplateDirectLink.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setToken(data.token);
|
||||
setIsEnabled(data.enabled);
|
||||
setCurrentStep('MANAGE');
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
setSelectedRecipientId(null);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Unable to create direct template access. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } =
|
||||
trpcReact.template.toggleTemplateDirectLink.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Direct link signing has been ${data.enabled ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
},
|
||||
onError: (_ctx, data) => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: `An error occurred while ${
|
||||
data.enabled ? 'enabling' : 'disabling'
|
||||
} direct link signing.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTemplateDirectLink, isLoading: isDeletingTemplateDirectLink } =
|
||||
trpcReact.template.deleteTemplateDirectLink.useMutation({
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
setToken(null);
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Direct template link deleted',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
setToken(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description:
|
||||
'We encountered an error while removing the direct template link. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onCopyClick = async (token: string) =>
|
||||
copy(formatDirectTemplatePath(token)).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The direct link has been copied to your clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
const onRecipientTableRowClick = async (recipientId: number) => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedRecipientId(recipientId);
|
||||
|
||||
await createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
directRecipientId: recipientId,
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
||||
|
||||
useEffect(() => {
|
||||
resetCreateTemplateDirectLink();
|
||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||
setSelectedRecipientId(null);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Direct Signing Link</DialogTitle>
|
||||
|
||||
<DialogDescription>Here's how it works:</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="mt-4 space-y-4 pl-12">
|
||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||
<li className="relative" key={index}>
|
||||
<div className="absolute -left-12">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold">{step.title}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{step.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{remaining.directTemplates === 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
Direct template link usage exceeded ({quota.directTemplates}/
|
||||
{quota.directTemplates})
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You have reached the maximum limit of {quota.directTemplates} direct
|
||||
templates.{' '}
|
||||
<Link
|
||||
className="mt-1 block underline underline-offset-4"
|
||||
href="/settings/billing"
|
||||
>
|
||||
Upgrade your account to continue!
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{remaining.directTemplates !== 0 && (
|
||||
<DialogFooter className="mx-auto mt-4">
|
||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||
Enable direct link signing
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||
<DialogContent className="relative">
|
||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose Direct Link Recipient</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Choose an existing recipient from below to continue
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validDirectTemplateRecipients.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-16 text-center">
|
||||
<p className="text-muted-foreground">No valid recipients found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{validDirectTemplateRecipients.map((row) => (
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={row.id}
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[row.role].roleName}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{selectedRecipientId === row.id ? (
|
||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||
{!template.Recipient.some(
|
||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
) && (
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">Or</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||
onClick={async () =>
|
||||
createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Create one automatically
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Direct Link Signing</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Manage the direct link signing for this template
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Label className="flex flex-row">
|
||||
Enable Direct Link Signing
|
||||
<Tooltip>
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
Disabling direct link signing will prevent anyone from accessing the link.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
|
||||
<Switch
|
||||
className="mt-2"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(value) => setIsEnabled(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="copy-direct-link">Copy Shareable Link</Label>
|
||||
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="copy-direct-link"
|
||||
disabled
|
||||
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
||||
readOnly
|
||||
className="pr-12"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
||||
<Button
|
||||
variant="none"
|
||||
type="button"
|
||||
className="h-8 w-8"
|
||||
onClick={() => void onCopyClick(token)}
|
||||
>
|
||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='mt-4'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-full sm:w-auto"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isTogglingTemplateAccess}
|
||||
onClick={async () =>
|
||||
toggleTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
enabled: isEnabled,
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that proceeding will remove direct linking recipient and turn it
|
||||
into a placeholder.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setCurrentStep('MANAGE')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
</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,146 +142,147 @@ 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">
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||
Use Template
|
||||
</Button>
|
||||
</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,158 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
|
||||
const ZConfigureDirectTemplateFormSchema = z.object({
|
||||
email: z.string().email('Email is invalid'),
|
||||
});
|
||||
|
||||
export type TConfigureDirectTemplateFormSchema = z.infer<typeof ZConfigureDirectTemplateFormSchema>;
|
||||
|
||||
export type ConfigureDirectTemplateFormProps = {
|
||||
flowStep: DocumentFlowStep;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
template: TemplateWithDetails;
|
||||
directTemplateRecipient: Recipient & { Field: Field[] };
|
||||
initialEmail?: string;
|
||||
onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void;
|
||||
};
|
||||
|
||||
export const ConfigureDirectTemplateFormPartial = ({
|
||||
flowStep,
|
||||
isDocumentPdfLoaded,
|
||||
template,
|
||||
directTemplateRecipient,
|
||||
initialEmail,
|
||||
onSubmit,
|
||||
}: ConfigureDirectTemplateFormProps) => {
|
||||
const { Recipient } = template;
|
||||
const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const recipientsWithBlankDirectRecipientEmail = Recipient.map((recipient) => {
|
||||
if (recipient.id === directTemplateRecipient.id) {
|
||||
return {
|
||||
...recipient,
|
||||
email: '',
|
||||
};
|
||||
}
|
||||
|
||||
return recipient;
|
||||
});
|
||||
|
||||
const form = useForm<TConfigureDirectTemplateFormSchema>({
|
||||
resolver: zodResolver(
|
||||
ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => {
|
||||
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Email cannot already exist in the template',
|
||||
path: ['email'],
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
email: initialEmail || '',
|
||||
},
|
||||
});
|
||||
|
||||
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
{isDocumentPdfLoaded &&
|
||||
directTemplateRecipient.Field.map((field, index) => (
|
||||
<ShowFieldItem
|
||||
key={index}
|
||||
field={field}
|
||||
recipients={recipientsWithBlankDirectRecipientEmail}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Form {...form}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Email</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
field.disabled ||
|
||||
derivedRecipientAccessAuth !== null ||
|
||||
session?.user.email !== undefined
|
||||
}
|
||||
placeholder="recipient@documenso.com"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!fieldState.error && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Enter your email address to receive the completed document.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</Form>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={flowStep.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={form.formState.isSubmitting}
|
||||
canGoBack={stepIndex !== 0}
|
||||
onGoBackClick={previousStep}
|
||||
onGoNextClick={form.handleSubmit(onSubmit)}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
159
apps/web/src/app/(recipient)/d/[token]/direct-template.tsx
Normal file
159
apps/web/src/app/(recipient)/d/[token]/direct-template.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
|
||||
import type { TConfigureDirectTemplateFormSchema } from './configure-direct-template';
|
||||
import { ConfigureDirectTemplateFormPartial } from './configure-direct-template';
|
||||
import type { DirectTemplateLocalField } from './sign-direct-template';
|
||||
import { SignDirectTemplateForm } from './sign-direct-template';
|
||||
|
||||
export type TemplatesDirectPageViewProps = {
|
||||
template: TemplateWithDetails;
|
||||
directTemplateToken: string;
|
||||
directTemplateRecipient: Recipient & { Field: Field[] };
|
||||
};
|
||||
|
||||
type DirectTemplateStep = 'configure' | 'sign';
|
||||
const DirectTemplateSteps: DirectTemplateStep[] = ['configure', 'sign'];
|
||||
|
||||
export const DirectTemplatePageView = ({
|
||||
template,
|
||||
directTemplateRecipient,
|
||||
directTemplateToken,
|
||||
}: TemplatesDirectPageViewProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { email, setEmail } = useRequiredSigningContext();
|
||||
const { recipient, setRecipient } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role];
|
||||
|
||||
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
||||
configure: {
|
||||
title: 'General',
|
||||
description: 'Preview and configure template.',
|
||||
stepIndex: 1,
|
||||
},
|
||||
sign: {
|
||||
title: `${recipientRoleDescription.actionVerb} document`,
|
||||
description: `${recipientRoleDescription.actionVerb} the document to complete the process.`,
|
||||
stepIndex: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const { mutateAsync: createDocumentFromDirectTemplate } =
|
||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||
|
||||
/**
|
||||
* Set the email into a temporary recipient so it can be used for reauth and signing email fields.
|
||||
*/
|
||||
const onConfigureDirectTemplateSubmit = ({ email }: TConfigureDirectTemplateFormSchema) => {
|
||||
setEmail(email);
|
||||
|
||||
setRecipient({
|
||||
...recipient,
|
||||
email,
|
||||
});
|
||||
|
||||
setStep('sign');
|
||||
};
|
||||
|
||||
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
|
||||
try {
|
||||
const token = await createDocumentFromDirectTemplate({
|
||||
directTemplateToken,
|
||||
directRecipientEmail: recipient.email,
|
||||
templateUpdatedAt: template.updatedAt,
|
||||
signedFieldValues: fields.map((field) => {
|
||||
if (!field.signedValue) {
|
||||
throw new Error('Invalid configuration');
|
||||
}
|
||||
|
||||
return field.signedValue;
|
||||
}),
|
||||
});
|
||||
|
||||
const redirectUrl = template.templateMeta?.redirectUrl;
|
||||
|
||||
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${token}/complete`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'We were unable to submit this document at this time. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const currentDocumentFlow = directTemplateFlow[step];
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={template.id}
|
||||
documentData={template.templateDocumentData}
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(DirectTemplateSteps[step - 1])}
|
||||
>
|
||||
<ConfigureDirectTemplateFormPartial
|
||||
flowStep={directTemplateFlow.configure}
|
||||
template={template}
|
||||
directTemplateRecipient={directTemplateRecipient}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onConfigureDirectTemplateSubmit}
|
||||
initialEmail={email}
|
||||
/>
|
||||
|
||||
<SignDirectTemplateForm
|
||||
flowStep={directTemplateFlow.sign}
|
||||
directRecipient={recipient}
|
||||
directRecipientFields={directTemplateRecipient.Field}
|
||||
template={template}
|
||||
onSubmit={onSignDirectTemplateSubmit}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
apps/web/src/app/(recipient)/d/[token]/not-found.tsx
Normal file
33
apps/web/src/app/(recipient)/d/[token]/not-found.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">404 Template not found</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
The template you are looking for may have been disabled, deleted or may have never
|
||||
existed.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button asChild className="w-32">
|
||||
<Link href="/">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/app/(recipient)/d/[token]/page.tsx
Normal file
92
apps/web/src/app/(recipient)/d/[token]/page.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
|
||||
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { DirectTemplatePageView } from './direct-template';
|
||||
import { DirectTemplateAuthPageView } from './signing-auth-page';
|
||||
|
||||
export type TemplatesDirectPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
|
||||
const template = await getTemplateByDirectLinkToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.directLink?.enabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const directTemplateRecipient = template.Recipient.find(
|
||||
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!directTemplateRecipient) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
// Ensure typesafety when we add more options.
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
||||
.with(null, () => true)
|
||||
.exhaustive();
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
return <DirectTemplateAuthPageView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
||||
<DocumentAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={directTemplateRecipient}
|
||||
user={user}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{truncateTitle(template.title)}
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
<p className="text-muted-foreground/80">
|
||||
{template.Recipient.length}{' '}
|
||||
{template.Recipient.length > 1 ? 'recipients' : 'recipient'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DirectTemplatePageView
|
||||
directTemplateRecipient={directTemplateRecipient}
|
||||
directTemplateToken={template.directLink.token}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
);
|
||||
}
|
||||
278
apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx
Normal file
278
apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } 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 { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
||||
|
||||
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
|
||||
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
|
||||
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
|
||||
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||
|
||||
export type SignDirectTemplateFormProps = {
|
||||
flowStep: DocumentFlowStep;
|
||||
directRecipient: Recipient;
|
||||
directRecipientFields: Field[];
|
||||
template: TemplateWithDetails;
|
||||
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DirectTemplateLocalField = Field & {
|
||||
signedValue?: TSignFieldWithTokenMutationSchema;
|
||||
Signature?: Signature;
|
||||
};
|
||||
|
||||
export const SignDirectTemplateForm = ({
|
||||
flowStep,
|
||||
directRecipient,
|
||||
directRecipientFields,
|
||||
template,
|
||||
onSubmit,
|
||||
}: SignDirectTemplateFormProps) => {
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||
|
||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
|
||||
setLocalFields(
|
||||
localFields.map((field) => {
|
||||
if (field.id !== value.fieldId) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const tempField: DirectTemplateLocalField = {
|
||||
...field,
|
||||
customText: value.value,
|
||||
inserted: true,
|
||||
signedValue: value,
|
||||
};
|
||||
|
||||
if (field.type === FieldType.SIGNATURE) {
|
||||
tempField.Signature = {
|
||||
id: 1,
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64: value.value,
|
||||
typedSignature: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
tempField.customText = DateTime.now()
|
||||
.setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
}
|
||||
return tempField;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => {
|
||||
setLocalFields(
|
||||
localFields.map((field) => {
|
||||
if (field.id !== value.fieldId) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
signedValue: undefined,
|
||||
Signature: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
||||
}, [localFields]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
validateFieldsInserted(localFields);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
const isFieldsValid = validateFieldsInserted(localFields);
|
||||
|
||||
if (!isFieldsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSubmit(localFields);
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
// Do not reset to false since we do a redirect.
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
Click to insert field
|
||||
</FieldToolTip>
|
||||
)}
|
||||
|
||||
{localFields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<NameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.TEXT, () => (
|
||||
<TextField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
|
||||
<Input
|
||||
id="full-name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={flowStep.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
<Button
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
onClick={previousStep}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit}
|
||||
documentTitle={template.title}
|
||||
fields={localFields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={directRecipient.role}
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
54
apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx
Normal file
54
apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const DirectTemplateAuthPageView = () => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleChangeAccount = async () => {
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
await signOut({
|
||||
callbackUrl: '/signin',
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'We were unable to log you out at this time.',
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsSigningOut(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Authentication required</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
You need to be logged in to view this page.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
type="submit"
|
||||
onClick={async () => handleChangeAccount()}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
apps/web/src/app/(recipient)/layout.tsx
Normal file
38
apps/web/src/app/(recipient)/layout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
type RecipientLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A layout to handle scenarios where the user is a recipient of a given resource
|
||||
* where we do not care whether they are authenticated or not.
|
||||
*
|
||||
* Such as direct template access, or signing.
|
||||
*/
|
||||
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
let teams: GetTeamsResponse = [];
|
||||
|
||||
if (user && session) {
|
||||
teams = await getTeams({ userId: user.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<div className="min-h-screen">
|
||||
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
||||
|
||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||
</div>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -61,7 +67,7 @@ export default async function CompletedSigningPage({
|
||||
|
||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
document,
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
userId: user?.id,
|
||||
});
|
||||
@ -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="mt-4 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>
|
||||
))}
|
||||
|
||||
<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>
|
||||
|
||||
@ -17,6 +17,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
@ -26,6 +30,8 @@ export type DateFieldProps = {
|
||||
recipient: Recipient;
|
||||
dateFormat?: string | null;
|
||||
timezone?: string | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const DateField = ({
|
||||
@ -33,6 +39,8 @@ export const DateField = ({
|
||||
recipient,
|
||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: DateFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
@ -58,12 +66,19 @@ export const DateField = ({
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
try {
|
||||
await signFieldWithToken({
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
authOptions,
|
||||
});
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
@ -85,10 +100,17 @@ export const DateField = ({
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -34,9 +34,9 @@ type PasskeyData = {
|
||||
|
||||
export type DocumentAuthContextValue = {
|
||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||
document: Document;
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
documentAuthOption: TDocumentAuthOptions;
|
||||
setDocument: (_value: Document) => void;
|
||||
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
|
||||
recipient: Recipient;
|
||||
recipientAuthOption: TRecipientAuthOptions;
|
||||
setRecipient: (_value: Recipient) => void;
|
||||
@ -69,19 +69,19 @@ export const useRequiredDocumentAuthContext = () => {
|
||||
};
|
||||
|
||||
export interface DocumentAuthProviderProps {
|
||||
document: Document;
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Recipient;
|
||||
user?: User | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DocumentAuthProvider = ({
|
||||
document: initialDocument,
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
recipient: initialRecipient,
|
||||
user,
|
||||
children,
|
||||
}: DocumentAuthProviderProps) => {
|
||||
const [document, setDocument] = useState(initialDocument);
|
||||
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
|
||||
const [recipient, setRecipient] = useState(initialRecipient);
|
||||
|
||||
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
|
||||
@ -95,10 +95,10 @@ export const DocumentAuthProvider = ({
|
||||
} = useMemo(
|
||||
() =>
|
||||
extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
documentAuth: documentAuthOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
}),
|
||||
[document, recipient],
|
||||
[documentAuthOptions, recipient],
|
||||
);
|
||||
|
||||
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
||||
@ -189,8 +189,8 @@ export const DocumentAuthProvider = ({
|
||||
<DocumentAuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
document,
|
||||
setDocument,
|
||||
documentAuthOptions,
|
||||
setDocumentAuthOptions,
|
||||
executeActionAuthProcedure,
|
||||
recipient,
|
||||
setRecipient,
|
||||
|
||||
@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
@ -20,9 +24,11 @@ import { SigningFieldContainer } from './signing-field-container';
|
||||
export type EmailFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -43,13 +49,22 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
try {
|
||||
await signFieldWithToken({
|
||||
const value = providedEmail ?? '';
|
||||
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: providedEmail ?? '',
|
||||
value,
|
||||
isBase64: false,
|
||||
authOptions,
|
||||
});
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
@ -71,10 +86,17 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
|
||||
@ -145,7 +145,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
@ -208,7 +208,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
|
||||
@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -25,9 +29,11 @@ import { SigningFieldContainer } from './signing-field-container';
|
||||
export type NameFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -83,13 +89,20 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken({
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value,
|
||||
isBase64: false,
|
||||
authOptions,
|
||||
});
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
@ -111,10 +124,17 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -58,7 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
|
||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
document,
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
userId: user?.id,
|
||||
});
|
||||
@ -119,8 +126,17 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||
>
|
||||
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
||||
<DocumentAuthProvider
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<SigningPageView
|
||||
recipient={recipient}
|
||||
document={document}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Document, Field } from '@documenso/prisma/client';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -16,7 +16,7 @@ import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
document: Document;
|
||||
documentTitle: string;
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
@ -25,14 +25,14 @@ export type SignDialogProps = {
|
||||
|
||||
export const SignDialog = ({
|
||||
isSubmitting,
|
||||
document,
|
||||
documentTitle,
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
role,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
const truncatedTitle = truncateTitle(documentTitle);
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
@ -40,18 +40,6 @@ export const SignDialog = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Reauth is currently not required for signing the document.
|
||||
// if (isAuthRedirectRequired) {
|
||||
// await executeActionAuthProcedure({
|
||||
// actionTarget: 'DOCUMENT',
|
||||
// onReauthFormSubmit: () => {
|
||||
// // Do nothing since the user should be redirected.
|
||||
// },
|
||||
// });
|
||||
|
||||
// return;
|
||||
// }
|
||||
|
||||
setShowDialog(open);
|
||||
};
|
||||
|
||||
|
||||
@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
@ -29,9 +33,16 @@ type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
export const SignatureField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: SignatureFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -105,13 +116,20 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken({
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value,
|
||||
isBase64: true,
|
||||
authOptions,
|
||||
});
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
@ -133,10 +151,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -24,9 +28,11 @@ import { SigningFieldContainer } from './signing-field-container';
|
||||
export type TextFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const TextField = ({ field, recipient }: TextFieldProps) => {
|
||||
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -81,13 +87,20 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken({
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: localText,
|
||||
isBase64: true,
|
||||
authOptions,
|
||||
});
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken(payload);
|
||||
|
||||
setLocalCustomText('');
|
||||
|
||||
@ -111,10 +124,17 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
await removeSignedFieldWithToken({
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken(payload);
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
@ -37,10 +37,13 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
<SignInForm initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||
<SignInForm
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
|
||||
/>
|
||||
|
||||
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
|
||||
@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
import { SignUpFormV2 } from '~/components/forms/v2/signup';
|
||||
@ -37,6 +37,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Globe, Lock } from 'lucide-react';
|
||||
import { Globe2, Lock } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||
@ -22,7 +22,7 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
||||
},
|
||||
PUBLIC: {
|
||||
label: 'Public',
|
||||
icon: Globe,
|
||||
icon: Globe2,
|
||||
color: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user