Compare commits
163 Commits
v1.5.4-rc.
...
v1.5.6-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||||
|
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||||
|
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
||||||
|
|
||||||
# [[URLS]]
|
# [[URLS]]
|
||||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||||
@@ -75,7 +79,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
|
|||||||
# OPTIONAL: Defines whether to force the use of TLS.
|
# OPTIONAL: Defines whether to force the use of TLS.
|
||||||
NEXT_PRIVATE_SMTP_SECURE=
|
NEXT_PRIVATE_SMTP_SECURE=
|
||||||
# REQUIRED: Defines the sender name to use for the from address.
|
# REQUIRED: Defines the sender name to use for the from address.
|
||||||
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
|
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
||||||
# REQUIRED: Defines the email address to use as the from address.
|
# REQUIRED: Defines the email address to use as the from address.
|
||||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
||||||
# OPTIONAL: The API key to use for Resend.com
|
# OPTIONAL: The API key to use for Resend.com
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
|||||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -33,9 +33,9 @@ jobs:
|
|||||||
- uses: ./.github/actions/cache-build
|
- uses: ./.github/actions/cache-build
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|||||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
|
|||||||
2
.github/workflows/issue-assignee-check.yml
vendored
2
.github/workflows/issue-assignee-check.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check Assigned User's Issue Count
|
- name: Check Assigned User's Issue Count
|
||||||
id: parse-comment
|
id: parse-comment
|
||||||
uses: actions/github-script@v5
|
uses: actions/github-script@v6
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
25
.github/workflows/issue-labeler.yml
vendored
Normal file
25
.github/workflows/issue-labeler.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Auto Label Assigned Issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [assigned]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label-when-assigned:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Label issue
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
script: |
|
||||||
|
const issue = context.issue;
|
||||||
|
// To run only on issues and not on PR
|
||||||
|
if (github.context.payload.issue.pull_request === undefined) {
|
||||||
|
const labelResponse = await github.rest.issues.addLabels({
|
||||||
|
owner: issue.owner,
|
||||||
|
repo: issue.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
labels: ['status: assigned']
|
||||||
|
});
|
||||||
|
}
|
||||||
2
.github/workflows/issue-opened.yml
vendored
2
.github/workflows/issue-opened.yml
vendored
@@ -17,5 +17,5 @@ jobs:
|
|||||||
issue_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
labels: ["needs triage"]
|
labels: ["status: triage"]
|
||||||
})
|
})
|
||||||
|
|||||||
4
.github/workflows/pr-review-reminder.yml
vendored
4
.github/workflows/pr-review-reminder.yml
vendored
@@ -2,14 +2,14 @@ name: 'PR Review Reminder'
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
|
types: ['opened', 'ready_for_review']
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checkPRs:
|
checkPRs:
|
||||||
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
|
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v4
|
- uses: actions/stale@v5
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
days-before-pr-stale: 90
|
days-before-pr-stale: 90
|
||||||
|
|||||||
14
.gitpod.yml
14
.gitpod.yml
@@ -29,16 +29,6 @@ ports:
|
|||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
|
|
||||||
|
|
||||||
github:
|
|
||||||
prebuilds:
|
|
||||||
master: true
|
|
||||||
pullRequests: true
|
|
||||||
pullRequestsFromForks: true
|
|
||||||
addCheck: true
|
|
||||||
addComment: true
|
|
||||||
addBadge: true
|
|
||||||
|
|
||||||
vscode:
|
vscode:
|
||||||
extensions:
|
extensions:
|
||||||
- aaron-bond.better-comments
|
- aaron-bond.better-comments
|
||||||
@@ -47,9 +37,5 @@ vscode:
|
|||||||
- esbenp.prettier-vscode
|
- esbenp.prettier-vscode
|
||||||
- mikestead.dotenv
|
- mikestead.dotenv
|
||||||
- unifiedjs.vscode-mdx
|
- unifiedjs.vscode-mdx
|
||||||
- GitHub.copilot-chat
|
|
||||||
- GitHub.copilot-labs
|
|
||||||
- GitHub.copilot
|
|
||||||
- GitHub.vscode-pull-request-github
|
- GitHub.vscode-pull-request-github
|
||||||
- Prisma.prisma
|
- Prisma.prisma
|
||||||
- VisualStudioExptTeam.vscodeintellicode
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p
|
|||||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||||
|
|
||||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||||
5. Place the certificate `/apps/web/resources/certificate.p12`
|
|
||||||
|
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -38,6 +42,7 @@ const config = {
|
|||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
|
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
2
apps/marketing/public/pdf.worker.min.js
vendored
2
apps/marketing/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
|||||||
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
|
import type { TClaimPlanRequestSchema } from './types';
|
||||||
|
import { ZClaimPlanResponseSchema } from './types';
|
||||||
|
|
||||||
export const claimPlan = async ({
|
export const claimPlan = async ({
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
|||||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
dataKey={metricKey as string}
|
dataKey={metricKey as string}
|
||||||
maxBarSize={60}
|
maxBarSize={60}
|
||||||
fill="hsl(var(--primary))"
|
fill="hsl(var(--primary))"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||||
const formattedData = data.map((item) => ({
|
const formattedData = data.map((item) => ({
|
||||||
amount: Number(item.amount),
|
amount: Number(item.amount),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
date: formatMonth(item.date as string),
|
date: formatMonth(item.date as string),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
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 = {
|
export type MonthlyCompletedDocumentsChartProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
data: GetUserMonthlyGrowthResult;
|
data: GetCompletedDocumentsMonthlyResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MonthlyCompletedDocumentsChart = ({
|
export const MonthlyCompletedDocumentsChart = ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
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 = {
|
export type TotalSignedDocumentsChartProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
data: GetUserMonthlyGrowthResult;
|
data: GetCompletedDocumentsMonthlyResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
|
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Variants, motion } from 'framer-motion';
|
import type { Variants } from 'framer-motion';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
import { TOSSFriendsSchema } from './schema';
|
import type { TOSSFriendsSchema } from './schema';
|
||||||
|
|
||||||
const ContainerVariants: Variants = {
|
const ContainerVariants: Variants = {
|
||||||
initial: {
|
initial: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const putFileData = await putFile(uploadedFile.file);
|
const putFileData = await putPdfFile(uploadedFile.file);
|
||||||
|
|
||||||
const documentToken = await createSinglePlayerDocument({
|
const documentToken = await createSinglePlayerDocument({
|
||||||
documentData: {
|
documentData: {
|
||||||
@@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
|||||||
expired: null,
|
expired: null,
|
||||||
signedAt: null,
|
signedAt: null,
|
||||||
readStatus: 'OPENED',
|
readStatus: 'OPENED',
|
||||||
|
documentDeletedAt: null,
|
||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
role: 'SIGNER',
|
role: 'SIGNER',
|
||||||
@@ -247,6 +248,7 @@ export const SinglePlayerClient = () => {
|
|||||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onFieldsSubmit}
|
onSubmit={onFieldsSubmit}
|
||||||
|
canGoBack={true}
|
||||||
isDocumentPdfLoaded={true}
|
isDocumentPdfLoaded={true}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MetadataRoute } from 'next';
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MetadataRoute } from 'next';
|
import type { MetadataRoute } from 'next';
|
||||||
|
|
||||||
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import LogoImage from '@documenso/assets/logo.png';
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
import { StatusWidgetContainer } from './status-widget-container';
|
// import { StatusWidgetContainer } from './status-widget-container';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
@@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
{/* <div className="mt-6">
|
||||||
<StatusWidgetContainer />
|
<StatusWidgetContainer />
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
|||||||
variants={HeroTitleVariants}
|
variants={HeroTitleVariants}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
animate="animate"
|
animate="animate"
|
||||||
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
|
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
|
||||||
>
|
>
|
||||||
Document signing,
|
Document signing,
|
||||||
<span className="block" /> finally open source.
|
<span className="block" /> finally open source.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const ShareConnectPaidWidgetBento = ({
|
|||||||
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
<CardContent className="grid grid-cols-1 gap-8 p-6">
|
||||||
<p className="text-foreground/80 leading-relaxed">
|
<p className="text-foreground/80 leading-relaxed">
|
||||||
<strong className="block">Get paid (Soon).</strong>
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
|
|||||||
export function StatusWidgetContainer() {
|
export function StatusWidgetContainer() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<StatusWidgetFallback />}>
|
<Suspense fallback={<StatusWidgetFallback />}>
|
||||||
<StatusWidget />
|
<StatusWidget slug="documenso-status" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { use, useMemo } from 'react';
|
import { memo, use } from 'react';
|
||||||
|
|
||||||
import type { Status } from '@openstatus/react';
|
import { type Status, getStatus } from '@openstatus/react';
|
||||||
import { getStatus } from '@openstatus/react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => {
|
|||||||
}[level];
|
}[level];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusWidget() {
|
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
|
||||||
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
|
const { status } = use(getStatus(slug));
|
||||||
const { status } = use(getStatusMemoized);
|
|
||||||
const level = getStatusLevel(status);
|
const level = getStatusLevel(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,4 +70,4 @@ export function StatusWidget() {
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
{signatureText && (
|
{signatureText && (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
|
'text-foreground truncate text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{signatureText}
|
{signatureText}
|
||||||
@@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="signatureText"
|
id="signatureText"
|
||||||
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
|
className="text-foreground placeholder:text-muted-foreground truncate border-none p-0 text-sm focus-visible:ring-0"
|
||||||
placeholder="Draw or type name here"
|
placeholder="Draw or type name here"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
{...register('signatureText', {
|
{...register('signatureText', {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { FieldError } from 'react-hook-form';
|
import type { FieldError } from 'react-hook-form';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SVGAttributes } from 'react';
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
DocumentDataType,
|
DocumentDataType,
|
||||||
|
DocumentSource,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
FieldType,
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
@@ -104,6 +105,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const document = await prisma.document.create({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
|
source: DocumentSource.DOCUMENT,
|
||||||
title: 'Documenso Supporter Pledge.pdf',
|
title: 'Documenso Supporter Pledge.pdf',
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
@@ -42,6 +46,7 @@ const config = {
|
|||||||
APP_VERSION: version,
|
APP_VERSION: version,
|
||||||
NEXT_PUBLIC_PROJECT: 'web',
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
|
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"cookie-es": "^1.0.0",
|
"cookie-es": "^1.0.0",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
|
|||||||
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_ID: string;
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: 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 Link from 'next/link';
|
||||||
|
|
||||||
import { type Document, DocumentStatus } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -17,9 +18,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type AdminActionsProps = {
|
export type AdminActionsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
document: Document;
|
document: Document;
|
||||||
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminActions = ({ className, document }: AdminActionsProps) => {
|
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
||||||
@@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
loading={isResealDocumentLoading}
|
loading={isResealDocumentLoading}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={recipients.some(
|
||||||
|
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||||
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
Reseal document
|
Reseal document
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
|||||||
|
|
||||||
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
||||||
|
|
||||||
<AdminActions className="mt-2" document={document} />
|
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
<h2 className="text-lg font-semibold">Recipients</h2>
|
<h2 className="text-lg font-semibold">Recipients</h2>
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
Loader,
|
||||||
|
MoreHorizontal,
|
||||||
|
ScrollTextIcon,
|
||||||
|
Share,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
|
|||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
||||||
@@ -50,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
|
|
||||||
const isOwner = document.User.id === session.user.id;
|
const isOwner = document.User.id === session.user.id;
|
||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||||
|
const isDeleted = document.deletedAt !== null;
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||||
const isDocumentDeletable = isOwner;
|
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
@@ -106,12 +116,22 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||||
|
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||||
|
Audit Log
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -138,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
<DeleteDocumentDialog
|
||||||
<DeleteDocumentDialog
|
id={document.id}
|
||||||
id={document.id}
|
status={document.status}
|
||||||
status={document.status}
|
documentTitle={document.title}
|
||||||
documentTitle={document.title}
|
open={isDeleteDialogOpen}
|
||||||
open={isDeleteDialogOpen}
|
canManageDocument={canManageDocument}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DuplicateDocumentDialog
|
<DuplicateDocumentDialog
|
||||||
id={document.id}
|
id={document.id}
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
|
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import {
|
import {
|
||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
FRIENDLY_STATUS_MAP,
|
FRIENDLY_STATUS_MAP,
|
||||||
@@ -34,7 +37,7 @@ export type DocumentPageViewProps = {
|
|||||||
params: {
|
params: {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
team?: Team;
|
team?: Team & { teamEmail: TeamEmail | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||||
@@ -83,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = await getRecipientsForDocument({
|
const [recipients, completedFields] = await Promise.all([
|
||||||
documentId,
|
getRecipientsForDocument({
|
||||||
teamId: team?.id,
|
documentId,
|
||||||
userId: user.id,
|
teamId: team?.id,
|
||||||
});
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getCompletedFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const documentWithRecipients = {
|
const documentWithRecipients = {
|
||||||
...document,
|
...document,
|
||||||
@@ -118,11 +126,17 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{document.status === DocumentStatus.PENDING && (
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={completedFields}
|
||||||
|
documentMeta={document.documentMeta || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export const EditDocumentForm = ({
|
|||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isDocumentEnterprise = await isUserEnterprise({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const document = await getDocumentWithDetailsById({
|
const document = await getDocumentWithDetailsById({
|
||||||
id: documentId,
|
id: documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -74,6 +69,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
@@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
<DownloadCertificateButton className="mr-2" documentId={document.id} />
|
<DownloadCertificateButton
|
||||||
|
className="mr-2"
|
||||||
|
documentId={document.id}
|
||||||
|
documentStatus={document.status}
|
||||||
|
/>
|
||||||
|
|
||||||
<DownloadAuditLogButton documentId={document.id} />
|
<DownloadAuditLogButton documentId={document.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type DownloadCertificateButtonProps = {
|
export type DownloadCertificateButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadCertificateButton = ({
|
export const DownloadCertificateButton = ({
|
||||||
className,
|
className,
|
||||||
documentId,
|
documentId,
|
||||||
|
documentStatus,
|
||||||
}: DownloadCertificateButtonProps) => {
|
}: DownloadCertificateButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({
|
|||||||
className={cn('w-full sm:w-auto', className)}
|
className={cn('w-full sm:w-auto', className)}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
||||||
onClick={() => void onDownloadCertificatesClick()}
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
>
|
>
|
||||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Share,
|
Share,
|
||||||
Trash2,
|
Trash2,
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
|
|||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||||
@@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
// const isPending = row.status === DocumentStatus.PENDING;
|
// const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isDocumentDeletable = isOwner;
|
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team?.url);
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
@@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger data-testid="document-table-action-btn">
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
{recipient && recipient?.role !== RecipientRole.CC && (
|
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
{recipient?.role === RecipientRole.VIEWER && (
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
@@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
@@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
{/* No point displaying this if there's no functionality. */}
|
||||||
|
{/* <DropdownMenuItem disabled>
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
Void
|
Void
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem> */}
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{canManageDocument ? 'Delete' : 'Hide'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
@@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
<DeleteDocumentDialog
|
||||||
<DeleteDocumentDialog
|
id={row.id}
|
||||||
id={row.id}
|
status={row.status}
|
||||||
status={row.status}
|
documentTitle={row.title}
|
||||||
documentTitle={row.title}
|
open={isDeleteDialogOpen}
|
||||||
open={isDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
teamId={team?.id}
|
||||||
teamId={team?.id}
|
canManageDocument={canManageDocument}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DuplicateDocumentDialog
|
<DuplicateDocumentDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
@@ -29,7 +30,7 @@ export type DocumentsDataTableProps = {
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
showSenderColumn?: boolean;
|
showSenderColumn?: boolean;
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({
|
export const DocumentsDataTable = ({
|
||||||
@@ -62,7 +63,12 @@ export const DocumentsDataTable = ({
|
|||||||
{
|
{
|
||||||
header: 'Created',
|
header: 'Created',
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
cell: ({ row }) => (
|
||||||
|
<LocaleDate
|
||||||
|
date={row.original.createdAt}
|
||||||
|
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
@@ -76,7 +82,12 @@ export const DocumentsDataTable = ({
|
|||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
accessorKey: 'recipient',
|
accessorKey: 'recipient',
|
||||||
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
|
cell: ({ row }) => (
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={row.original.Recipient}
|
||||||
|
documentStatus={row.original.status}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
|
|||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
canManageDocument: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteDocumentDialog = ({
|
export const DeleteDocumentDialog = ({
|
||||||
@@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
status,
|
status,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
teamId,
|
teamId,
|
||||||
|
canManageDocument,
|
||||||
}: DeleteDocumentDialogProps) => {
|
}: DeleteDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({
|
|||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Please note that this action is irreversible. Once confirmed, your document will be
|
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
|
||||||
permanently deleted.
|
<strong>"{documentTitle}"</strong>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{status !== DocumentStatus.DRAFT && (
|
{canManageDocument ? (
|
||||||
<div className="mt-4">
|
<Alert variant="warning" className="-mt-1">
|
||||||
<Input
|
{match(status)
|
||||||
type="text"
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
value={inputValue}
|
<AlertDescription>
|
||||||
onChange={onInputChange}
|
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||||
placeholder="Type 'delete' to confirm"
|
this document will be permanently deleted.
|
||||||
/>
|
</AlertDescription>
|
||||||
</div>
|
))
|
||||||
|
.with(DocumentStatus.PENDING, () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
Please note that this action is <strong>irreversible</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1">Once confirmed, the following will occur:</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>Document will be permanently deleted</li>
|
||||||
|
<li>Document signing process will be cancelled</li>
|
||||||
|
<li>All inserted signatures will be voided</li>
|
||||||
|
<li>All recipients will be notified</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
|
<AlertDescription>
|
||||||
|
<p>By deleting this document, the following will occur:</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>The document will be hidden from your account</li>
|
||||||
|
<li>Recipients will still retain their copy of the document</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="warning" className="-mt-1">
|
||||||
|
<AlertDescription>
|
||||||
|
Please contact support if you would like to revert this action.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status !== DocumentStatus.DRAFT && canManageDocument && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder="Type 'delete' to confirm"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
<Button
|
Cancel
|
||||||
type="button"
|
</Button>
|
||||||
variant="secondary"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={!isDeleteEnabled}
|
disabled={!isDeleteEnabled && canManageDocument}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1"
|
>
|
||||||
>
|
{canManageDocument ? 'Delete' : 'Hide'}
|
||||||
Delete
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
|||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
||||||
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
|
const currentTeam = team
|
||||||
|
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const getStatOptions: GetStatsInput = {
|
const getStatOptions: GetStatsInput = {
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
<div
|
||||||
|
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
|
||||||
|
data-testid="empty-document-state"
|
||||||
|
>
|
||||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const { type, data } = await putFile(file);
|
const { type, data } = await putPdfFile(file);
|
||||||
|
|
||||||
const { id: documentDataId } = await createDocumentData({
|
const { id: documentDataId } = await createDocumentData({
|
||||||
type,
|
type,
|
||||||
@@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error(error);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error instanceof TRPCClientError) {
|
console.error(err);
|
||||||
|
|
||||||
|
if (error.code === 'INVALID_DOCUMENT_FILE') {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid file',
|
||||||
|
description: 'You cannot upload encrypted PDFs',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else if (err instanceof TRPCClientError) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: error.message,
|
description: err.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
import {
|
||||||
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
SKIP_QUERY_BATCH_META,
|
||||||
|
} from '@documenso/lib/constants/trpc';
|
||||||
|
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@@ -19,52 +23,135 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
|
|||||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||||
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
||||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||||
|
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
|
||||||
|
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type EditTemplateFormProps = {
|
export type EditTemplateFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
initialTemplate: TemplateWithDetails;
|
||||||
template: Template;
|
isEnterprise: boolean;
|
||||||
recipients: Recipient[];
|
|
||||||
fields: Field[];
|
|
||||||
documentData: DocumentData;
|
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditTemplateStep = 'signers' | 'fields';
|
type EditTemplateStep = 'settings' | 'signers' | 'fields';
|
||||||
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
|
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
||||||
|
|
||||||
export const EditTemplateForm = ({
|
export const EditTemplateForm = ({
|
||||||
|
initialTemplate,
|
||||||
className,
|
className,
|
||||||
template,
|
isEnterprise,
|
||||||
recipients,
|
|
||||||
fields,
|
|
||||||
user: _user,
|
|
||||||
documentData,
|
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
}: EditTemplateFormProps) => {
|
}: EditTemplateFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditTemplateStep>('signers');
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<EditTemplateStep>('settings');
|
||||||
|
|
||||||
|
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: template, refetch: refetchTemplate } =
|
||||||
|
trpc.template.getTemplateWithDetailsById.useQuery(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialTemplate,
|
||||||
|
...SKIP_QUERY_BATCH_META,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { Recipient: recipients, Field: fields, templateDocumentData } = template;
|
||||||
|
|
||||||
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||||
|
settings: {
|
||||||
|
title: 'General',
|
||||||
|
description: 'Configure general settings for the template.',
|
||||||
|
stepIndex: 1,
|
||||||
|
},
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Placeholders',
|
title: 'Add Placeholders',
|
||||||
description: 'Add all relevant placeholders for each recipient.',
|
description: 'Add all relevant placeholders for each recipient.',
|
||||||
stepIndex: 1,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: 'Add Fields',
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: 'Add all relevant fields for each recipient.',
|
||||||
stepIndex: 2,
|
stepIndex: 3,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
|
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({
|
||||||
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.template.getTemplateWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.template.getTemplateWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.template.getTemplateWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updateTemplateSettings({
|
||||||
|
templateId: template.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||||
|
globalActionAuth: data.globalActionAuth ?? null,
|
||||||
|
},
|
||||||
|
meta: data.meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('signers');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while updating the document settings.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAddTemplatePlaceholderFormSubmit = async (
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
@@ -72,9 +159,11 @@ export const EditTemplateForm = ({
|
|||||||
try {
|
try {
|
||||||
await addTemplateSigners({
|
await addTemplateSigners({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
|
teamId: team?.id,
|
||||||
signers: data.signers,
|
signers: data.signers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
@@ -100,6 +189,9 @@ export const EditTemplateForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
router.push(templateRootPath);
|
router.push(templateRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
@@ -110,6 +202,15 @@ export const EditTemplateForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the data in the background when steps change.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
void refetchTemplate();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
<Card
|
<Card
|
||||||
@@ -117,7 +218,11 @@ export const EditTemplateForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
<LazyPDFViewer
|
||||||
|
key={templateDocumentData.id}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -135,12 +240,26 @@ export const EditTemplateForm = ({
|
|||||||
currentStep={currentDocumentFlow.stepIndex}
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||||
>
|
>
|
||||||
|
<AddTemplateSettingsFormPartial
|
||||||
|
key={recipients.length}
|
||||||
|
template={template}
|
||||||
|
documentFlow={documentFlow.settings}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
|
/>
|
||||||
|
|
||||||
<AddTemplatePlaceholderRecipientsFormPartial
|
<AddTemplatePlaceholderRecipientsFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplateFieldsFormPartial
|
<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 { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||||
import { EditTemplateForm } from './edit-template';
|
import { EditTemplateForm } from './edit-template';
|
||||||
|
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
|
||||||
|
|
||||||
export type TemplatePageViewProps = {
|
export type TemplatePageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -35,7 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const template = await getTemplateById({
|
const template = await getTemplateWithDetailsById({
|
||||||
id: templateId,
|
id: templateId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
@@ -44,42 +45,47 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
redirect(templateRootPath);
|
redirect(templateRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { templateDocumentData } = template;
|
const isTemplateEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
const [templateRecipients, templateFields] = await Promise.all([
|
teamId: team?.id,
|
||||||
getRecipientsForTemplate({
|
});
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
getFieldsForTemplate({
|
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
<div className="flex flex-col justify-between sm:flex-row">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<div>
|
||||||
Templates
|
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
</Link>
|
<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}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
{template.title}
|
{template.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center">
|
||||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<EditTemplateForm
|
<EditTemplateForm
|
||||||
className="mt-8"
|
className="mt-6"
|
||||||
template={template}
|
initialTemplate={template}
|
||||||
user={user}
|
|
||||||
recipients={templateRecipients}
|
|
||||||
fields={templateFields}
|
|
||||||
documentData={templateDocumentData}
|
|
||||||
templateRootPath={templateRootPath}
|
templateRootPath={templateRootPath}
|
||||||
|
isEnterprise={isTemplateEnterprise}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
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 { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import type { Template } from '@documenso/prisma/client';
|
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
|
|
||||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||||
|
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: Template;
|
row: FindTemplateRow;
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
};
|
};
|
||||||
@@ -33,6 +34,7 @@ export const DataTableActionDropdown = ({
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -66,6 +68,11 @@ export const DataTableActionDropdown = ({
|
|||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
|
||||||
|
<Share2Icon className="mr-2 h-4 w-4" />
|
||||||
|
Direct link
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!isOwner && !isTeamTemplate}
|
disabled={!isOwner && !isTeamTemplate}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
@@ -82,6 +89,12 @@ export const DataTableActionDropdown = ({
|
|||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TemplateDirectLinkDialog
|
||||||
|
template={row}
|
||||||
|
open={isTemplateDirectLinkDialogOpen}
|
||||||
|
onOpenChange={setTemplateDirectLinkDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<DeleteTemplateDialog
|
<DeleteTemplateDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
|
|||||||
@@ -4,32 +4,26 @@ import { useTransition } from 'react';
|
|||||||
|
|
||||||
import Link from 'next/link';
|
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 { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
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 { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
import { DataTableTitle } from './data-table-title';
|
import { DataTableTitle } from './data-table-title';
|
||||||
|
import { TemplateDirectLinkBadge } from './template-direct-link-badge';
|
||||||
import { UseTemplateDialog } from './use-template-dialog';
|
import { UseTemplateDialog } from './use-template-dialog';
|
||||||
|
|
||||||
type TemplateWithRecipient = Template & {
|
|
||||||
Recipient: Recipient[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type TemplatesDataTableProps = {
|
type TemplatesDataTableProps = {
|
||||||
templates: Array<
|
templates: FindTemplateRow[];
|
||||||
TemplateWithRecipient & {
|
|
||||||
team: { id: number; url: string } | null;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@@ -48,6 +42,7 @@ export const TemplatesDataTable = ({
|
|||||||
teamId,
|
teamId,
|
||||||
}: TemplatesDataTableProps) => {
|
}: TemplatesDataTableProps) => {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
@@ -88,9 +83,70 @@ export const TemplatesDataTable = ({
|
|||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
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',
|
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',
|
header: 'Actions',
|
||||||
|
|||||||
@@ -1,48 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { FilePlus, Loader } from 'lucide-react';
|
||||||
import { FilePlus, X } from 'lucide-react';
|
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
|
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZCreateTemplateFormSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
|
||||||
|
|
||||||
type NewTemplateDialogProps = {
|
type NewTemplateDialogProps = {
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
@@ -54,51 +35,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<TCreateTemplateFormSchema>({
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZCreateTemplateFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
|
|
||||||
trpc.template.createTemplate.useMutation();
|
|
||||||
|
|
||||||
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
||||||
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
if (isUploadingFile) {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
|
||||||
|
|
||||||
setUploadedFile({
|
|
||||||
file,
|
|
||||||
fileBase64: `data:application/pdf;base64,${base64String}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!form.getValues('name')) {
|
|
||||||
form.setValue('name', file.name);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (values: TCreateTemplateFormSchema) => {
|
|
||||||
if (!uploadedFile) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file: File = uploadedFile.file;
|
setIsUploadingFile(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { type, data } = await putFile(file);
|
const { type, data } = await putPdfFile(file);
|
||||||
|
|
||||||
const { id: templateDocumentDataId } = await createDocumentData({
|
const { id: templateDocumentDataId } = await createDocumentData({
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
@@ -106,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
teamId,
|
teamId,
|
||||||
title: values.name ? values.name : file.name,
|
title: file.name,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,25 +76,16 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
description: 'Please try again later.',
|
description: 'Please try again later.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setIsUploadingFile(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
if (form.getValues('name') === uploadedFile?.file.name) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadedFile(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showNewTemplateDialog) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [form, showNewTemplateDialog]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
|
<Dialog
|
||||||
|
open={showNewTemplateDialog}
|
||||||
|
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
||||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
@@ -154,81 +95,29 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
<DialogContent className="w-full max-w-xl">
|
<DialogContent className="w-full max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-4">New Template</DialogTitle>
|
<DialogTitle>New Template</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Templates allow you to quickly generate documents with pre-filled recipients and fields.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div>
|
<div className="relative">
|
||||||
<Form {...form}>
|
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||||
<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>
|
{isUploadingFile && (
|
||||||
<Label htmlFor="template">Upload a Document</Label>
|
<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 className="my-3">
|
</div>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Plus } from 'lucide-react';
|
import { InfoIcon, Plus } from 'lucide-react';
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||||
|
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||||
|
} from '@documenso/lib/constants/template';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -19,24 +26,59 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
const ZAddRecipientsForNewDocumentSchema = z
|
||||||
recipients: z.array(
|
.object({
|
||||||
z.object({
|
sendDocument: z.boolean(),
|
||||||
email: z.string().email(),
|
recipients: z.array(
|
||||||
name: z.string(),
|
z.object({
|
||||||
role: z.nativeEnum(RecipientRole),
|
id: z.number(),
|
||||||
}),
|
email: z.string().email(),
|
||||||
),
|
name: z.string(),
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
// Display exactly which rows are duplicates.
|
||||||
|
.superRefine((items, ctx) => {
|
||||||
|
const uniqueEmails = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const [index, recipients] of items.recipients.entries()) {
|
||||||
|
const email = recipients.email.toLowerCase();
|
||||||
|
|
||||||
|
const firstFoundIndex = uniqueEmails.get(email);
|
||||||
|
|
||||||
|
if (firstFoundIndex === undefined) {
|
||||||
|
uniqueEmails.set(email, index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['recipients', index, 'email'],
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Emails must be unique',
|
||||||
|
path: ['recipients', firstFoundIndex, 'email'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
@@ -54,35 +96,33 @@ export function UseTemplateDialog({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const {
|
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TAddRecipientsForNewDocumentSchema>({
|
|
||||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
recipients:
|
sendDocument: false,
|
||||||
recipients.length > 0
|
recipients: recipients.map((recipient) => {
|
||||||
? recipients.map((recipient) => ({
|
const isRecipientEmailPlaceholder = recipient.email.match(
|
||||||
nativeId: recipient.id,
|
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||||
formId: String(recipient.id),
|
);
|
||||||
name: recipient.name,
|
|
||||||
email: recipient.email,
|
const isRecipientNamePlaceholder = recipient.name.match(
|
||||||
role: recipient.role,
|
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||||
}))
|
);
|
||||||
: [
|
|
||||||
{
|
return {
|
||||||
name: '',
|
id: recipient.id,
|
||||||
email: '',
|
name: !isRecipientNamePlaceholder ? recipient.name : '',
|
||||||
role: RecipientRole.SIGNER,
|
email: !isRecipientEmailPlaceholder ? recipient.email : '',
|
||||||
},
|
};
|
||||||
],
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
@@ -91,6 +131,7 @@ export function UseTemplateDialog({
|
|||||||
templateId,
|
templateId,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
|
sendDocument: data.sendDocument,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -101,146 +142,147 @@ export function UseTemplateDialog({
|
|||||||
|
|
||||||
router.push(`${documentRootPath}/${id}`);
|
router.push(`${documentRootPath}/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const toastPayload: Toast = {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while creating document from template.',
|
description: 'An error occurred while creating document from template.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (error.code === 'DOCUMENT_SEND_FAILED') {
|
||||||
|
toastPayload.description = 'The document was created but could not be sent to recipients.';
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(toastPayload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
|
|
||||||
|
|
||||||
const { fields: formRecipients } = useFieldArray({
|
const { fields: formRecipients } = useFieldArray({
|
||||||
control,
|
control: form.control,
|
||||||
name: 'recipients',
|
name: 'recipients',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer">
|
<Button variant="outline" className="bg-background">
|
||||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Use Template
|
Use Template
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Document Recipients</DialogTitle>
|
<DialogTitle>Create document from template</DialogTitle>
|
||||||
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
|
<DialogDescription>
|
||||||
|
{recipients.length === 0
|
||||||
|
? 'A draft document will be created'
|
||||||
|
: 'Add the recipients to create the document with'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{formRecipients.map((recipient, index) => (
|
|
||||||
<div
|
|
||||||
key={recipient.id}
|
|
||||||
data-native-id={recipient.id}
|
|
||||||
className="flex flex-wrap items-end gap-x-4"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label htmlFor={`recipient-${recipient.id}-email`}>
|
|
||||||
Email
|
|
||||||
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Controller
|
<Form {...form}>
|
||||||
control={control}
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
name={`recipients.${index}.email`}
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
render={({ field }) => (
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
<Input
|
{formRecipients.map((recipient, index) => (
|
||||||
id={`recipient-${recipient.id}-email`}
|
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||||
type="email"
|
<FormField
|
||||||
className="bg-background mt-2"
|
control={form.control}
|
||||||
disabled={isSubmitting}
|
name={`recipients.${index}.email`}
|
||||||
{...field}
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel required>Email</FormLabel>}
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={recipients[index].email || 'Email'} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
<FormField
|
||||||
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
|
control={form.control}
|
||||||
|
name={`recipients.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel>Name</FormLabel>}
|
||||||
|
|
||||||
<Controller
|
<FormControl>
|
||||||
control={control}
|
<Input {...field} placeholder={recipients[index].name || 'Name'} />
|
||||||
name={`recipients.${index}.name`}
|
</FormControl>
|
||||||
render={({ field }) => (
|
<FormMessage />
|
||||||
<Input
|
</FormItem>
|
||||||
id={`recipient-${recipient.id}-name`}
|
)}
|
||||||
type="text"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
{...field}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
/>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[60px]">
|
{recipients.length > 0 && (
|
||||||
<Controller
|
<div className="mt-4 flex flex-row items-center">
|
||||||
control={control}
|
<FormField
|
||||||
name={`recipients.${index}.role`}
|
control={form.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
name="sendDocument"
|
||||||
<Select value={value} onValueChange={(x) => onChange(x)}>
|
render={({ field }) => (
|
||||||
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
<FormItem>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="sendDocument"
|
||||||
|
className="h-5 w-5"
|
||||||
|
checkClassName="dark:text-white text-primary"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<SelectContent className="" align="end">
|
<label
|
||||||
<SelectItem value={RecipientRole.SIGNER}>
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
<div className="flex items-center">
|
htmlFor="sendDocument"
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
>
|
||||||
Signer
|
Send document
|
||||||
</div>
|
<Tooltip>
|
||||||
</SelectItem>
|
<TooltipTrigger type="button">
|
||||||
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.CC}>
|
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||||
<div className="flex items-center">
|
<p>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
The document will be immediately sent to recipients if this is
|
||||||
Receives copy
|
checked.
|
||||||
</div>
|
</p>
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.APPROVER}>
|
<p>Otherwise, the document will be created as a draft.</p>
|
||||||
<div className="flex items-center">
|
</TooltipContent>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
</Tooltip>
|
||||||
Approver
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.VIEWER}>
|
<DialogFooter>
|
||||||
<div className="flex items-center">
|
<DialogClose asChild>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
<Button type="button" variant="secondary">
|
||||||
Viewer
|
Close
|
||||||
</div>
|
</Button>
|
||||||
</SelectItem>
|
</DialogClose>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full">
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
|
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</fieldset>
|
||||||
))}
|
</form>
|
||||||
</div>
|
</Form>
|
||||||
|
|
||||||
<DialogFooter className="justify-end">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="secondary">
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
loading={isCreatingDocumentFromTemplate}
|
|
||||||
disabled={isCreatingDocumentFromTemplate}
|
|
||||||
onClick={onCreateDocumentFromTemplate}
|
|
||||||
>
|
|
||||||
Create Document
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
156
apps/web/src/app/(recipient)/d/[token]/direct-template.tsx
Normal file
156
apps/web/src/app/(recipient)/d/[token]/direct-template.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
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 directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
||||||
|
configure: {
|
||||||
|
title: 'General',
|
||||||
|
description: 'Preview and configure template.',
|
||||||
|
stepIndex: 1,
|
||||||
|
},
|
||||||
|
sign: {
|
||||||
|
title: 'Sign document',
|
||||||
|
description: 'Sign 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 { CheckCircle2, Clock8 } from 'lucide-react';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { env } from 'next-runtime-env';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
@@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
|
|||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
import { SigningAuthPageView } from '../signing-auth-page';
|
import { SigningAuthPageView } from '../signing-auth-page';
|
||||||
|
import { ClaimAccount } from './claim-account';
|
||||||
import { DocumentPreviewButton } from './document-preview-button';
|
import { DocumentPreviewButton } from './document-preview-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
@@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
|
|||||||
export default async function CompletedSigningPage({
|
export default async function CompletedSigningPage({
|
||||||
params: { token },
|
params: { token },
|
||||||
}: CompletedSigningPageProps) {
|
}: CompletedSigningPageProps) {
|
||||||
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
@@ -61,7 +67,7 @@ export default async function CompletedSigningPage({
|
|||||||
|
|
||||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
type: 'ACCESS',
|
type: 'ACCESS',
|
||||||
document,
|
documentAuthOptions: document.authOptions,
|
||||||
recipient,
|
recipient,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
});
|
});
|
||||||
@@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
|
|||||||
|
|
||||||
const sessionData = await getServerSession();
|
const sessionData = await getServerSession();
|
||||||
const isLoggedIn = !!sessionData?.user;
|
const isLoggedIn = !!sessionData?.user;
|
||||||
|
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
<div
|
||||||
{/* Card with recipient */}
|
className={cn(
|
||||||
<SigningCard3D
|
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||||
name={recipientName}
|
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||||
signature={signatures.at(0)}
|
)}
|
||||||
signingCelebrationImage={signingCelebration}
|
>
|
||||||
/>
|
<div
|
||||||
|
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||||
|
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||||
|
canSignUp,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col items-center', {
|
||||||
|
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||||
|
{truncatedTitle}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
{/* Card with recipient */}
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
<SigningCard3D
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
name={recipientName}
|
||||||
<div className="text-documenso-700 flex items-center text-center">
|
signature={signatures.at(0)}
|
||||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
signingCelebrationImage={signingCelebration}
|
||||||
<span className="text-sm">Everyone has signed</span>
|
/>
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.with({ deletedAt: null }, () => (
|
|
||||||
<div className="flex items-center text-center text-blue-600">
|
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
|
||||||
<span className="text-sm">Waiting for others to sign</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<div className="flex items-center text-center text-red-600">
|
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
|
||||||
<span className="text-sm">Document no longer available to sign</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
You have
|
Document
|
||||||
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
{recipient.role === RecipientRole.SIGNER && ' Signed '}
|
||||||
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
|
||||||
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
{recipient.role === RecipientRole.APPROVER && ' Approved '}
|
||||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
</h2>
|
||||||
</h2>
|
|
||||||
|
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
||||||
Everyone has signed! You will receive an Email copy of the signed document.
|
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||||
</p>
|
<span className="text-sm">Everyone has signed</span>
|
||||||
))
|
</div>
|
||||||
.with({ deletedAt: null }, () => (
|
))
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
.with({ deletedAt: null }, () => (
|
||||||
You will receive an Email copy of the signed document once everyone has signed.
|
<div className="mt-4 flex items-center text-center text-blue-600">
|
||||||
</p>
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
))
|
<span className="text-sm">Waiting for others to sign</span>
|
||||||
.otherwise(() => (
|
</div>
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
))
|
||||||
This document has been cancelled by the owner and is no longer available for others to
|
.otherwise(() => (
|
||||||
sign.
|
<div className="flex items-center text-center text-red-600">
|
||||||
</p>
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
))}
|
<span className="text-sm">Document no longer available to sign</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
Everyone has signed! You will receive an Email copy of the signed document.
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.with({ deletedAt: null }, () => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
You will receive an Email copy of the signed document once everyone has signed.
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
This document has been cancelled by the owner and is no longer available for others
|
||||||
|
to sign.
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
{document.status === DocumentStatus.COMPLETED ? (
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentDownloadButton
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
className="flex-1"
|
|
||||||
fileName={document.title}
|
{document.status === DocumentStatus.COMPLETED ? (
|
||||||
documentData={documentData}
|
<DocumentDownloadButton
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
className="flex-1"
|
||||||
/>
|
fileName={document.title}
|
||||||
) : (
|
documentData={documentData}
|
||||||
<DocumentPreviewButton
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
className="text-[11px]"
|
/>
|
||||||
title="Signatures will appear once the document has been completed"
|
) : (
|
||||||
documentData={documentData}
|
<DocumentPreviewButton
|
||||||
/>
|
className="text-[11px]"
|
||||||
)}
|
title="Signatures will appear once the document has been completed"
|
||||||
|
documentData={documentData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoggedIn ? (
|
{canSignUp && (
|
||||||
|
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
|
||||||
|
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
|
||||||
|
Need to sign documents?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
|
||||||
|
Create your account and start using state-of-the-art document signing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoggedIn && (
|
||||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||||
Go Back Home
|
Go Back Home
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
|
||||||
Want to send slick signing links like this one?{' '}
|
|
||||||
<Link
|
|
||||||
href="https://documenso.com"
|
|
||||||
className="text-documenso-700 hover:text-documenso-600"
|
|
||||||
>
|
|
||||||
Check out Documenso.
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
@@ -26,6 +30,8 @@ export type DateFieldProps = {
|
|||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
dateFormat?: string | null;
|
dateFormat?: string | null;
|
||||||
timezone?: string | null;
|
timezone?: string | null;
|
||||||
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DateField = ({
|
export const DateField = ({
|
||||||
@@ -33,6 +39,8 @@ export const DateField = ({
|
|||||||
recipient,
|
recipient,
|
||||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
onSignField,
|
||||||
|
onUnsignField,
|
||||||
}: DateFieldProps) => {
|
}: DateFieldProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -58,12 +66,19 @@ export const DateField = ({
|
|||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -85,10 +100,17 @@ export const DateField = ({
|
|||||||
|
|
||||||
const onRemove = async () => {
|
const onRemove = async () => {
|
||||||
try {
|
try {
|
||||||
await removeSignedFieldWithToken({
|
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onUnsignField) {
|
||||||
|
await onUnsignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSignedFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
|
|
||||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
|
||||||
@@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
|
|||||||
<FormLabel required>2FA token</FormLabel>
|
<FormLabel required>2FA token</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Token" />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ type PasskeyData = {
|
|||||||
|
|
||||||
export type DocumentAuthContextValue = {
|
export type DocumentAuthContextValue = {
|
||||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||||
document: Document;
|
documentAuthOptions: Document['authOptions'];
|
||||||
documentAuthOption: TDocumentAuthOptions;
|
documentAuthOption: TDocumentAuthOptions;
|
||||||
setDocument: (_value: Document) => void;
|
setDocumentAuthOptions: (_value: Document['authOptions']) => void;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
recipientAuthOption: TRecipientAuthOptions;
|
recipientAuthOption: TRecipientAuthOptions;
|
||||||
setRecipient: (_value: Recipient) => void;
|
setRecipient: (_value: Recipient) => void;
|
||||||
@@ -69,19 +69,19 @@ export const useRequiredDocumentAuthContext = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface DocumentAuthProviderProps {
|
export interface DocumentAuthProviderProps {
|
||||||
document: Document;
|
documentAuthOptions: Document['authOptions'];
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentAuthProvider = ({
|
export const DocumentAuthProvider = ({
|
||||||
document: initialDocument,
|
documentAuthOptions: initialDocumentAuthOptions,
|
||||||
recipient: initialRecipient,
|
recipient: initialRecipient,
|
||||||
user,
|
user,
|
||||||
children,
|
children,
|
||||||
}: DocumentAuthProviderProps) => {
|
}: DocumentAuthProviderProps) => {
|
||||||
const [document, setDocument] = useState(initialDocument);
|
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
|
||||||
const [recipient, setRecipient] = useState(initialRecipient);
|
const [recipient, setRecipient] = useState(initialRecipient);
|
||||||
|
|
||||||
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
|
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
|
||||||
@@ -95,10 +95,10 @@ export const DocumentAuthProvider = ({
|
|||||||
} = useMemo(
|
} = useMemo(
|
||||||
() =>
|
() =>
|
||||||
extractDocumentAuthMethods({
|
extractDocumentAuthMethods({
|
||||||
documentAuth: document.authOptions,
|
documentAuth: documentAuthOptions,
|
||||||
recipientAuth: recipient.authOptions,
|
recipientAuth: recipient.authOptions,
|
||||||
}),
|
}),
|
||||||
[document, recipient],
|
[documentAuthOptions, recipient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
||||||
@@ -189,8 +189,8 @@ export const DocumentAuthProvider = ({
|
|||||||
<DocumentAuthContext.Provider
|
<DocumentAuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
document,
|
documentAuthOptions,
|
||||||
setDocument,
|
setDocumentAuthOptions,
|
||||||
executeActionAuthProcedure,
|
executeActionAuthProcedure,
|
||||||
recipient,
|
recipient,
|
||||||
setRecipient,
|
setRecipient,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
@@ -20,9 +24,11 @@ import { SigningFieldContainer } from './signing-field-container';
|
|||||||
export type EmailFieldProps = {
|
export type EmailFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
recipient: Recipient;
|
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 router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -43,13 +49,22 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
|||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
const value = providedEmail ?? '';
|
||||||
|
|
||||||
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: providedEmail ?? '',
|
value,
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -71,10 +86,17 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
|||||||
|
|
||||||
const onRemove = async () => {
|
const onRemove = async () => {
|
||||||
try {
|
try {
|
||||||
await removeSignedFieldWithToken({
|
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onUnsignField) {
|
||||||
|
await onUnsignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSignedFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
<SignDialog
|
<SignDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
document={document}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
@@ -208,7 +208,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
<SignDialog
|
<SignDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
document={document}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={recipient.role}
|
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 Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@@ -25,9 +29,11 @@ import { SigningFieldContainer } from './signing-field-container';
|
|||||||
export type NameFieldProps = {
|
export type NameFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
recipient: Recipient;
|
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 router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -83,13 +89,20 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await signFieldWithToken({
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value,
|
value,
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -111,10 +124,17 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
|
|
||||||
const onRemove = async () => {
|
const onRemove = async () => {
|
||||||
try {
|
try {
|
||||||
await removeSignedFieldWithToken({
|
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onUnsignField) {
|
||||||
|
await onUnsignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSignedFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} 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 { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
@@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@@ -45,9 +46,15 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
getCompletedFieldsForToken({ token }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (
|
||||||
|
!document ||
|
||||||
|
!document.documentData ||
|
||||||
|
!recipient ||
|
||||||
|
document.status === DocumentStatus.DRAFT
|
||||||
|
) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
type: 'ACCESS',
|
type: 'ACCESS',
|
||||||
document,
|
documentAuthOptions: document.authOptions,
|
||||||
recipient,
|
recipient,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
});
|
});
|
||||||
@@ -119,8 +126,17 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||||
>
|
>
|
||||||
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
<DocumentAuthProvider
|
||||||
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
documentAuthOptions={document.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<SigningPageView
|
||||||
|
recipient={recipient}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
completedFields={completedFields}
|
||||||
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
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 { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +16,7 @@ import { truncateTitle } from '~/helpers/truncate-title';
|
|||||||
|
|
||||||
export type SignDialogProps = {
|
export type SignDialogProps = {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
document: Document;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
@@ -25,14 +25,14 @@ export type SignDialogProps = {
|
|||||||
|
|
||||||
export const SignDialog = ({
|
export const SignDialog = ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
document,
|
documentTitle,
|
||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
}: SignDialogProps) => {
|
}: SignDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(documentTitle);
|
||||||
const isComplete = fields.every((field) => field.inserted);
|
const isComplete = fields.every((field) => field.inserted);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
@@ -40,18 +40,6 @@ export const SignDialog = ({
|
|||||||
return;
|
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);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|||||||
import { type Recipient } from '@documenso/prisma/client';
|
import { type Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
@@ -29,9 +33,16 @@ type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
|||||||
export type SignatureFieldProps = {
|
export type SignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
recipient: Recipient;
|
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 router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -105,13 +116,20 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await signFieldWithToken({
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value,
|
value,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -133,10 +151,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
const onRemove = async () => {
|
const onRemove = async () => {
|
||||||
try {
|
try {
|
||||||
await removeSignedFieldWithToken({
|
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onUnsignField) {
|
||||||
|
await onUnsignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSignedFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} 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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
@@ -23,9 +25,15 @@ export type SigningPageViewProps = {
|
|||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
completedFields: CompletedField[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
|
export const SigningPageView = ({
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
completedFields,
|
||||||
|
}: SigningPageViewProps) => {
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
@@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DocumentReadOnlyFields fields={completedFields} />
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
{fields.map((field) =>
|
{fields.map((field) =>
|
||||||
match(field.type)
|
match(field.type)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
@@ -24,9 +28,11 @@ import { SigningFieldContainer } from './signing-field-container';
|
|||||||
export type TextFieldProps = {
|
export type TextFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
recipient: Recipient;
|
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 router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -81,13 +87,20 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await signFieldWithToken({
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: localText,
|
value: localText,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signFieldWithToken(payload);
|
||||||
|
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
|
|
||||||
@@ -111,10 +124,17 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
|
|
||||||
const onRemove = async () => {
|
const onRemove = async () => {
|
||||||
try {
|
try {
|
||||||
await removeSignedFieldWithToken({
|
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onUnsignField) {
|
||||||
|
await onUnsignField(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeSignedFieldWithToken(payload);
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
||||||
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
|
|||||||
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
|
let tokens: GetTeamTokensResponse | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
{match(error.code)
|
||||||
|
.with(AppErrorCode.UNAUTHORIZED, () => error.message)
|
||||||
|
.otherwise(() => 'Something went wrong.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
export default function SignatureDisclosure() {
|
export default function SignatureDisclosure() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<article className="prose">
|
<article className="prose dark:prose-invert">
|
||||||
<h1>Electronic Signature Disclosure</h1>
|
<h1>Electronic Signature Disclosure</h1>
|
||||||
|
|
||||||
<h2>Welcome</h2>
|
<h2>Welcome</h2>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { env } from 'next-runtime-env';
|
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 { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
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">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Welcome back, we are lucky to have you.
|
Welcome back, we are lucky to have you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="-mx-6 my-4" />
|
<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' && (
|
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<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 { 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 { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
import { SignUpFormV2 } from '~/components/forms/v2/signup';
|
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"
|
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
|
||||||
initialEmail={email || undefined}
|
initialEmail={email || undefined}
|
||||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
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 { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
|
|||||||
|
|
||||||
export type AvatarWithRecipientProps = {
|
export type AvatarWithRecipientProps = {
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
||||||
|
|
||||||
const onRecipientClick = () => {
|
const onRecipientClick = () => {
|
||||||
if (!recipient.token) {
|
if (!signingToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: 'Copied to clipboard',
|
||||||
description: 'The signing link has been copied to your clipboard.',
|
description: 'The signing link has been copied to your clipboard.',
|
||||||
@@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('my-1 flex items-center gap-2', {
|
className={cn('my-1 flex items-center gap-2', {
|
||||||
'cursor-pointer hover:underline': recipient.token,
|
'cursor-pointer hover:underline': signingToken,
|
||||||
})}
|
})}
|
||||||
role={recipient.token ? 'button' : undefined}
|
role={signingToken ? 'button' : undefined}
|
||||||
title={recipient.token && 'Click to copy signing link for sending to recipient'}
|
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||||
onClick={onRecipientClick}
|
onClick={onRecipientClick}
|
||||||
>
|
>
|
||||||
<StackAvatar
|
<StackAvatar
|
||||||
@@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<div>
|
|
||||||
<div
|
<div
|
||||||
className="text-muted-foreground text-sm"
|
className="text-muted-foreground text-sm"
|
||||||
title="Click to copy signing link for sending to recipient"
|
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||||
>
|
>
|
||||||
<p>{recipient.email} </p>
|
<p>{recipient.email}</p>
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
import { StackAvatars } from './stack-avatars';
|
import { StackAvatars } from './stack-avatars';
|
||||||
|
|
||||||
export type StackAvatarsWithTooltipProps = {
|
export type StackAvatarsWithTooltipProps = {
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
position?: 'top' | 'bottom';
|
position?: 'top' | 'bottom';
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StackAvatarsWithTooltip = ({
|
export const StackAvatarsWithTooltip = ({
|
||||||
|
documentStatus,
|
||||||
recipients,
|
recipients,
|
||||||
position,
|
position,
|
||||||
children,
|
children,
|
||||||
}: StackAvatarsWithTooltipProps) => {
|
}: StackAvatarsWithTooltipProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const isControlled = useRef(false);
|
|
||||||
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const waitingRecipients = recipients.filter(
|
const waitingRecipients = recipients.filter(
|
||||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||||
);
|
);
|
||||||
@@ -44,105 +39,74 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
(recipient) => getRecipientType(recipient) === 'unsigned',
|
(recipient) => getRecipientType(recipient) === 'unsigned',
|
||||||
);
|
);
|
||||||
|
|
||||||
const onMouseEnter = () => {
|
|
||||||
if (isMouseOverTimeout.current) {
|
|
||||||
clearTimeout(isMouseOverTimeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isControlled.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isMouseOverTimeout.current = setTimeout(() => {
|
|
||||||
setOpen((o) => (!o ? true : o));
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
|
||||||
if (isMouseOverTimeout.current) {
|
|
||||||
clearTimeout(isMouseOverTimeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isControlled.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen((o) => (o ? false : o));
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenChange = (newOpen: boolean) => {
|
|
||||||
isControlled.current = newOpen;
|
|
||||||
|
|
||||||
setOpen(newOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<PopoverHover
|
||||||
<PopoverTrigger
|
trigger={children || <StackAvatars recipients={recipients} />}
|
||||||
className="flex cursor-pointer"
|
contentProps={{
|
||||||
onMouseEnter={onMouseEnter}
|
className: 'flex flex-col gap-y-5 py-2',
|
||||||
onMouseLeave={onMouseLeave}
|
side: position,
|
||||||
>
|
}}
|
||||||
{children || <StackAvatars recipients={recipients} />}
|
>
|
||||||
</PopoverTrigger>
|
{completedRecipients.length > 0 && (
|
||||||
|
<div>
|
||||||
<PopoverContent
|
<h1 className="text-base font-medium">Completed</h1>
|
||||||
side={position}
|
{completedRecipients.map((recipient: Recipient) => (
|
||||||
onMouseEnter={onMouseEnter}
|
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||||
onMouseLeave={onMouseLeave}
|
<StackAvatar
|
||||||
className="flex flex-col gap-y-5 py-2"
|
first={true}
|
||||||
>
|
key={recipient.id}
|
||||||
{completedRecipients.length > 0 && (
|
type={getRecipientType(recipient)}
|
||||||
<div>
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
<h1 className="text-base font-medium">Completed</h1>
|
/>
|
||||||
{completedRecipients.map((recipient: Recipient) => (
|
<div className="">
|
||||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||||
<StackAvatar
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
first={true}
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
key={recipient.id}
|
</p>
|
||||||
type={getRecipientType(recipient)}
|
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
|
||||||
/>
|
|
||||||
<div className="">
|
|
||||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
|
||||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{waitingRecipients.length > 0 && (
|
{waitingRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Waiting</h1>
|
<h1 className="text-base font-medium">Waiting</h1>
|
||||||
{waitingRecipients.map((recipient: Recipient) => (
|
{waitingRecipients.map((recipient: Recipient) => (
|
||||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
))}
|
key={recipient.id}
|
||||||
</div>
|
recipient={recipient}
|
||||||
)}
|
documentStatus={documentStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{openedRecipients.length > 0 && (
|
{openedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Opened</h1>
|
<h1 className="text-base font-medium">Opened</h1>
|
||||||
{openedRecipients.map((recipient: Recipient) => (
|
{openedRecipients.map((recipient: Recipient) => (
|
||||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
))}
|
key={recipient.id}
|
||||||
</div>
|
recipient={recipient}
|
||||||
)}
|
documentStatus={documentStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{uncompletedRecipients.length > 0 && (
|
{uncompletedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
))}
|
key={recipient.id}
|
||||||
</div>
|
recipient={recipient}
|
||||||
)}
|
documentStatus={documentStatus}
|
||||||
</PopoverContent>
|
/>
|
||||||
</Popover>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverHover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
} from '@documenso/lib/constants/trpc';
|
} from '@documenso/lib/constants/trpc';
|
||||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -71,7 +69,6 @@ export type CommandMenuProps = {
|
|||||||
|
|
||||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOwner = useCallback(
|
|
||||||
(document: Document) => document.userId === session?.user.id,
|
|
||||||
[session?.user.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSigningLink = useCallback(
|
|
||||||
(recipients: Recipient[]) =>
|
|
||||||
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
|
|
||||||
[session?.user.email],
|
|
||||||
);
|
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
if (!searchDocumentsData) {
|
if (!searchDocumentsData) {
|
||||||
return [];
|
return [];
|
||||||
@@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
|
|
||||||
return searchDocumentsData.map((document) => ({
|
return searchDocumentsData.map((document) => ({
|
||||||
label: document.title,
|
label: document.title,
|
||||||
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
path: document.path,
|
||||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
value: document.value,
|
||||||
}));
|
}));
|
||||||
}, [searchDocumentsData, isOwner, getSigningLink]);
|
}, [searchDocumentsData]);
|
||||||
|
|
||||||
const currentPage = pages[pages.length - 1];
|
const currentPage = pages[pages.length - 1];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -12,8 +10,6 @@ import { getRootHref } from '@documenso/lib/utils/params';
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { CommandMenu } from '../common/command-menu';
|
|
||||||
|
|
||||||
const navigationLinks = [
|
const navigationLinks = [
|
||||||
{
|
{
|
||||||
href: '/documents',
|
href: '/documents',
|
||||||
@@ -25,13 +21,14 @@ const navigationLinks = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
setIsCommandMenuOpen: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||||
|
|
||||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||||
@@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
||||||
onClick={() => setOpen((open) => !open)}
|
onClick={() => setIsCommandMenuOpen(true)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Search className="mr-2 h-5 w-5" />
|
<Search className="mr-2 h-5 w-5" />
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
<Logo className="h-6 w-auto" />
|
<Logo className="h-6 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<DesktopNav />
|
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
||||||
|
|
||||||
<div className="flex gap-x-4 md:ml-8">
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
<MenuSwitcher user={user} teams={teams} />
|
<MenuSwitcher user={user} teams={teams} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
const MotionLink = motion(Link);
|
||||||
|
|
||||||
export type MenuSwitcherProps = {
|
export type MenuSwitcherProps = {
|
||||||
user: User;
|
user: User;
|
||||||
teams: GetTeamsResponse;
|
teams: GetTeamsResponse;
|
||||||
@@ -93,7 +96,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
<Button
|
<Button
|
||||||
data-testid="menu-switcher"
|
data-testid="menu-switcher"
|
||||||
variant="none"
|
variant="none"
|
||||||
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
|
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||||
>
|
>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||||
@@ -102,12 +105,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
rightSideComponent={
|
rightSideComponent={
|
||||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||||
}
|
}
|
||||||
|
textSectionClassName="hidden lg:flex"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||||
align="end"
|
align="end"
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
@@ -169,18 +173,43 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
<DropdownMenuItem asChild key={team.id}>
|
||||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
<MotionLink
|
||||||
|
initial="initial"
|
||||||
|
animate="initial"
|
||||||
|
whileHover="animate"
|
||||||
|
href={formatRedirectUrlOnSwitch(team.url)}
|
||||||
|
>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
avatarFallback={formatAvatarFallback(team.name)}
|
||||||
primaryText={team.name}
|
primaryText={team.name}
|
||||||
secondaryText={formatSecondaryAvatarText(team)}
|
secondaryText={
|
||||||
|
<div className="relative">
|
||||||
|
<motion.span
|
||||||
|
className="overflow-hidden"
|
||||||
|
variants={{
|
||||||
|
initial: { opacity: 1, translateY: 0 },
|
||||||
|
animate: { opacity: 0, translateY: '100%' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatSecondaryAvatarText(team)}
|
||||||
|
</motion.span>
|
||||||
|
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-0"
|
||||||
|
variants={{
|
||||||
|
initial: { opacity: 0, translateY: '100%' },
|
||||||
|
animate: { opacity: 1, translateY: 0 },
|
||||||
|
}}
|
||||||
|
>{`/t/${team.url}`}</motion.span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
rightSideComponent={
|
rightSideComponent={
|
||||||
isPathTeamUrl(team.url) && (
|
isPathTeamUrl(team.url) && (
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</MotionLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
<SheetContent className="flex w-full max-w-[350px] flex-col">
|
||||||
<Link href="/" onClick={handleMenuItemClick}>
|
<Link href="/" onClick={handleMenuItemClick}>
|
||||||
<Image
|
<Image
|
||||||
src={LogoImage}
|
src={LogoImage}
|
||||||
@@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
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 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 { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||||
@@ -22,7 +22,7 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
|||||||
},
|
},
|
||||||
PUBLIC: {
|
PUBLIC: {
|
||||||
label: 'Public',
|
label: 'Public',
|
||||||
icon: Globe,
|
icon: Globe2,
|
||||||
color: 'text-green-500 dark:text-green-300',
|
color: 'text-green-500 dark:text-green-300',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZDisable2FAForm = z.object({
|
export const ZDisable2FAForm = z.object({
|
||||||
@@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Token" />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
@@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="text" value={field.value ?? ''} />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
@@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Token" />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/br
|
|||||||
import { KeyRoundIcon } from 'lucide-react';
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { FaIdCardClip } from 'react-icons/fa6';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||||
|
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||||
@@ -68,9 +70,15 @@ export type SignInFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isOIDCSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
export const SignInForm = ({
|
||||||
|
className,
|
||||||
|
initialEmail,
|
||||||
|
isGoogleSSOEnabled,
|
||||||
|
isOIDCSSOEnabled,
|
||||||
|
}: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
@@ -256,6 +264,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithOIDCClick = async () => {
|
||||||
|
try {
|
||||||
|
await signIn('oidc', { callbackUrl: LOGIN_REDIRECT_PATH });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to sign you In. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -316,7 +337,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
|
{(isGoogleSSOEnabled || isPasskeyEnabled || isOIDCSSOEnabled) && (
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||||
@@ -338,6 +359,20 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isOIDCSSOEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignInWithOIDCClick}
|
||||||
|
>
|
||||||
|
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||||
|
OIDC
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPasskeyEnabled && (
|
{isPasskeyEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -372,9 +407,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
name="totpCode"
|
name="totpCode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Authentication Token</FormLabel>
|
<FormLabel>Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} />
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
|
{Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<PinInputGroup key={i}>
|
||||||
|
<PinInputSlot index={i} />
|
||||||
|
</PinInputGroup>
|
||||||
|
))}
|
||||||
|
</PinInput>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -52,9 +52,15 @@ export type SignUpFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isOIDCSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
|
export const SignUpForm = ({
|
||||||
|
className,
|
||||||
|
initialEmail,
|
||||||
|
isGoogleSSOEnabled,
|
||||||
|
isOIDCSSOEnabled,
|
||||||
|
}: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -121,6 +127,19 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignUpWithOIDCClick = async () => {
|
||||||
|
try {
|
||||||
|
await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH });
|
||||||
|
} catch (err) {
|
||||||
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -221,6 +240,28 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isOIDCSSOEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
<span className="text-muted-foreground bg-transparent">Or</span>
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant={'outline'}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignUpWithOIDCClick}
|
||||||
|
>
|
||||||
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
|
Sign Up with OIDC
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user