mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 12:32:34 +10:00
Compare commits
1 Commits
feat/delet
...
v1.8.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e4ab7c56 |
54
.github/workflows/publish.yml
vendored
54
.github/workflows/publish.yml
vendored
@ -89,35 +89,22 @@ jobs:
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
# Check if the version is stable (no rc or beta in the version)
|
||||
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
documenso/documenso:latest \
|
||||
--amend documenso/documenso-amd64:latest \
|
||||
--amend documenso/documenso-arm64:latest
|
||||
|
||||
docker manifest push documenso/documenso:latest
|
||||
fi
|
||||
|
||||
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
documenso/documenso:rc \
|
||||
--amend documenso/documenso-amd64:rc \
|
||||
--amend documenso/documenso-arm64:rc
|
||||
|
||||
docker manifest push documenso/documenso:rc
|
||||
fi
|
||||
docker manifest create \
|
||||
documenso/documenso:latest \
|
||||
--amend documenso/documenso-amd64:latest \
|
||||
--amend documenso/documenso-arm64:latest \
|
||||
|
||||
docker manifest create \
|
||||
documenso/documenso:$GIT_SHA \
|
||||
--amend documenso/documenso-amd64:$GIT_SHA \
|
||||
--amend documenso/documenso-arm64:$GIT_SHA
|
||||
--amend documenso/documenso-arm64:$GIT_SHA \
|
||||
|
||||
docker manifest create \
|
||||
documenso/documenso:$APP_VERSION \
|
||||
--amend documenso/documenso-amd64:$APP_VERSION \
|
||||
--amend documenso/documenso-arm64:$APP_VERSION
|
||||
--amend documenso/documenso-arm64:$APP_VERSION \
|
||||
|
||||
docker manifest push documenso/documenso:latest
|
||||
docker manifest push documenso/documenso:$GIT_SHA
|
||||
docker manifest push documenso/documenso:$APP_VERSION
|
||||
|
||||
@ -126,34 +113,21 @@ jobs:
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
# Check if the version is stable (no rc or beta in the version)
|
||||
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:latest \
|
||||
--amend ghcr.io/documenso/documenso-amd64:latest \
|
||||
--amend ghcr.io/documenso/documenso-arm64:latest
|
||||
|
||||
docker manifest push ghcr.io/documenso/documenso:latest
|
||||
fi
|
||||
|
||||
if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:rc \
|
||||
--amend ghcr.io/documenso/documenso-amd64:rc \
|
||||
--amend ghcr.io/documenso/documenso-arm64:rc
|
||||
|
||||
docker manifest push ghcr.io/documenso/documenso:rc
|
||||
fi
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:latest \
|
||||
--amend ghcr.io/documenso/documenso-amd64:latest \
|
||||
--amend ghcr.io/documenso/documenso-arm64:latest \
|
||||
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:$GIT_SHA \
|
||||
--amend ghcr.io/documenso/documenso-amd64:$GIT_SHA \
|
||||
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA
|
||||
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA \
|
||||
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:$APP_VERSION \
|
||||
--amend ghcr.io/documenso/documenso-amd64:$APP_VERSION \
|
||||
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION
|
||||
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION \
|
||||
|
||||
docker manifest push ghcr.io/documenso/documenso:latest
|
||||
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
|
||||
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION
|
||||
|
||||
@ -27,6 +27,9 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@ -11,10 +11,6 @@ Digitally signing documents requires a signing certificate in `.p12` format. You
|
||||
|
||||
Follow the steps below to create a free, self-signed certificate for local development.
|
||||
|
||||
<Callout type="warning">
|
||||
These steps should be run on a UNIX based system, otherwise you may run into an error.
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
|
||||
### Generate Private Key
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
---
|
||||
title: Project Babel
|
||||
description: We are announcing Project Babel - an initiative to support all languages of the world on Documenso.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-11-08
|
||||
tags:
|
||||
- Languages
|
||||
- Community
|
||||
- Open Source
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/babel.png"
|
||||
width="800"
|
||||
height="800"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">The tower of Babel to add some Gravitas to this project.</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; We are opening up translations to the community. Read this to add a language: https://documen.so/babel-fish
|
||||
|
||||
## Announcing Project Babel: Powering Documenso with Global Language Support
|
||||
|
||||
At Documenso, we believe that open source is more than just a software philosophy—it’s a way to build solutions that are open to all. Now, we’re happy to take that mission further with Project Babel, a community-driven initiative designed to bring worldwide language support to the Documenso platform. This project aims to enable Documenso to support as many languages as possible.
|
||||
|
||||
## Why Language Support Matters
|
||||
|
||||
We already have customers from 36 different countries and are seeing traffic from even more. When it comes to critical tools like signature platforms, having a user interface in your native language can make all the difference. No matter who and where you are, your team deserves tools that are fully accessible and intuitive. That’s why we’re making it our goal to support every language, and we need your help to make it happen! We’re building Documenso as a truly global public commodity.
|
||||
|
||||
## The Vision Behind Project Babel
|
||||
|
||||
The goal of Project Babel is simple but bold: We want to out-ship and out-customize every other document signing tool worldwide. How? By leveraging the collective power of our global community.
|
||||
|
||||
Unlike closed-source software, where localization means waiting for updates from the core team, Project Babel lets anyone contribute a new language, improve an existing translation, or customize the experience to meet local cultural nuances. This flexibility isn’t just a bonus—it’s the baseline for truly global products.
|
||||
|
||||
Through Project Babel, you can help make Documenso the most inclusive e-signature tool. Whether by adding a language you speak or fine-tuning existing translations, you’re shaping a platform that works for everyone, everywhere.
|
||||
|
||||
## How You Can Contribute
|
||||
|
||||
We’ve created a simple GitHub-based contribution flow to get started. We’ll improve the flow and user experience as the project progresses. As always, your contributions are highly valued.
|
||||
|
||||
Check out the contribution guide here: [https://documen.so/babel-fish](https://documen.so/babel-fish)
|
||||
|
||||
## Open Source Makes It Possible
|
||||
|
||||
Closed-source solutions can’t keep up with the speed or depth of customization that open source offers. While other companies might take months or years to localize their products, Documenso can adapt and grow in real-time, thanks to contributions from our community. Whether it’s a small regional dialect or a widely spoken language, Project Babel ensures that Documenso evolves to meet the needs of people everywhere.
|
||||
|
||||
> More importantly, this initiative empowers users. It allows you to control your software experience, ensuring it reflects your culture, language, and unique needs.
|
||||
|
||||
Project Babel is more than a localization effort—it’s the first step toward democratizing access to highly customized software for everyone, no matter where they are or what language they speak. We’re incredibly excited about this initiative, but it can only succeed with your participation. We invite you to join us in making Documenso the most linguistically inclusive platform out there.
|
||||
|
||||
Ready to get started? Check out the full tutorial and become part of the Babel community today! Let’s build open signing for the world: https://documen.so/babel-fish
|
||||
|
||||
If you have any questions or comments, reach out on [Twitter / X](https://twitter.com/eltimuro) (DMs open) or [Discord](https://documen.so/discord).
|
||||
|
||||
Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales)
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
@ -8,59 +8,6 @@ Check out what's new in the latest version and read our thoughts on it. For more
|
||||
|
||||
---
|
||||
|
||||
# Documenso v1.8.0: Team Preferences, Signature Rejection, and Document Distribution
|
||||
|
||||
We're excited to announce the release of Documenso v1.8.0! This update brings powerful new features to enhance your document signing process. Here's what's new:
|
||||
|
||||
## 🌟 Key New Features
|
||||
|
||||
### 1. Team Preferences
|
||||
|
||||
Introducing **Team Preferences**, allowing administrators to configure settings and preferences that apply to documents across the entire team. This feature ensures consistency and simplifies management by letting you set default options, permissions, and preferences that automatically apply to all team members.
|
||||
|
||||

|
||||
|
||||
### 2. Signature Rejection
|
||||
|
||||
Recipients now have the option to **reject signatures**. This feature enhances communication by allowing recipients to decline signing, providing feedback or requesting changes before the document is finalized.
|
||||
|
||||
<video
|
||||
src="/changelog/v1_8_0/reject-document.mp4"
|
||||
className="aspect-video w-full"
|
||||
autoPlay
|
||||
loop
|
||||
controls
|
||||
/>
|
||||
|
||||
### 3. Document Distribution Settings
|
||||
|
||||
With the new **Document Distribution Settings**, you have greater control over how your documents are shared. Distribute communications via our automated emails and templates or take full control using our API and your own notifications infrastructure.
|
||||
|
||||
## 🔧 Other Improvements
|
||||
|
||||
- **Support for Gmail SMTP Service**: Adds support for using Gmail as your SMTP service provider.
|
||||
- **Certificate and Email Translations**: Added support for multiple languages in document certificates and emails, enhancing the experience for international users.
|
||||
- **Field Movement Fixes**: Resolved issues related to moving fields within documents, improving the document preparation experience.
|
||||
- **Docker Environment Update**: Improved Docker setup for smoother deployments and better environment consistency.
|
||||
- **Billing Access Improvements**: Users now have uninterrupted access to billing information, simplifying account management.
|
||||
- **Support Time Windows for 2FA Tokens**: Enhanced two-factor authentication by supporting time windows in 2FA tokens, improving flexibility.
|
||||
|
||||
## 💡 Recent Features
|
||||
|
||||
Don't forget to take advantage of these powerful features from our recent releases:
|
||||
|
||||
- **Signing Order**: Define the sequence in which recipients sign your documents for a structured signing process.
|
||||
- **Document Visibility Controls**: Manage who can view your documents and at what stages, offering greater privacy and control.
|
||||
- **Embedded Signing Experience**: Integrate the signing process directly into your own applications for a seamless user experience.
|
||||
|
||||
**👏 Thank You**
|
||||
|
||||
As always, we're grateful for the community's contributions and feedback. Your support helps us improve Documenso and deliver a top-notch open-source document signing solution.
|
||||
|
||||
We hope you enjoy the new features in Documenso v1.8.0. Happy signing!
|
||||
|
||||
---
|
||||
|
||||
# Documenso v1.7.1: Signing order and document visibility
|
||||
|
||||
We're excited to introduce Documenso v1.7.1, bringing you improved control over your document signing process. Here are the key updates:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.8.1-rc.1",
|
||||
"version": "1.8.0-rc.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
4
apps/marketing/process-env.d.ts
vendored
4
apps/marketing/process-env.d.ts
vendored
@ -2,8 +2,8 @@ declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
|
||||
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?:string;
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 467 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 169 KiB |
@ -163,7 +163,6 @@ export const SinglePlayerClient = () => {
|
||||
expired: null,
|
||||
signedAt: null,
|
||||
readStatus: 'OPENED',
|
||||
rejectionReason: null,
|
||||
documentDeletedAt: null,
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.8.1-rc.1",
|
||||
"version": "1.8.0-rc.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -28,7 +28,6 @@
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"colord": "^2.9.3",
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
@ -54,7 +53,7 @@
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"recharts": "^2.7.2",
|
||||
"remeda": "^2.17.3",
|
||||
"remeda": "^2.12.1",
|
||||
"sharp": "0.32.6",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
|
||||
2
apps/web/process-env.d.ts
vendored
2
apps/web/process-env.d.ts
vendored
@ -2,7 +2,7 @@ declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?:string;
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ export const DocumentPageViewInformation = ({
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const documentInformation = useMemo(() => {
|
||||
const info = [
|
||||
return [
|
||||
{
|
||||
description: msg`Uploaded by`,
|
||||
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email,
|
||||
@ -44,20 +44,8 @@ export const DocumentPageViewInformation = ({
|
||||
.toRelative(),
|
||||
},
|
||||
];
|
||||
|
||||
if (document.deletedAt) {
|
||||
info.push({
|
||||
description: msg`Deleted`,
|
||||
value:
|
||||
document.deletedAt &&
|
||||
DateTime.fromJSDate(document.deletedAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toFormat('MMMM d, yyyy'),
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}, [isMounted, document, i18n.locales?.[0] || i18n.locale, userId]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMounted, document, userId]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||
|
||||
@ -4,7 +4,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
||||
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -133,11 +133,6 @@ export const DocumentPageViewRecentActivity = ({
|
||||
<CheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<MailOpen className="h-3 w-3" aria-hidden="true" />
|
||||
|
||||
@ -4,15 +4,7 @@ import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
MailIcon,
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
@ -23,7 +15,6 @@ import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentPageViewRecipientsProps = {
|
||||
@ -132,26 +123,6 @@ export const DocumentPageViewRecipients = ({
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Badge variant="destructive">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<Trans>Rejected</Trans>
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<Trans>Reason for rejection: </Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{recipient.rejectionReason}
|
||||
</p>
|
||||
</PopoverHover>
|
||||
)}
|
||||
|
||||
{document.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
|
||||
@ -146,10 +146,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
@ -221,7 +218,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
||||
{match(document.status)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Trans>This document has been signed by all recipients</Trans>
|
||||
|
||||
@ -109,10 +109,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
|
||||
@ -121,10 +121,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
|
||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
@ -142,7 +139,6 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
className="mr-2"
|
||||
documentId={document.id}
|
||||
documentStatus={document.status}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
|
||||
<DownloadAuditLogButton teamId={team?.id} documentId={document.id} />
|
||||
|
||||
@ -14,14 +14,12 @@ export type DownloadCertificateButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
documentStatus: DocumentStatus;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const DownloadCertificateButton = ({
|
||||
className,
|
||||
documentId,
|
||||
documentStatus,
|
||||
teamId,
|
||||
}: DownloadCertificateButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
@ -31,7 +29,7 @@ export const DownloadCertificateButton = ({
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadCertificate({ documentId, teamId });
|
||||
const { url } = await downloadCertificate({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
|
||||
@ -7,7 +7,6 @@ import Link from 'next/link';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import {
|
||||
ArchiveRestore,
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
@ -24,8 +23,8 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
@ -44,7 +43,6 @@ import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||
import { MoveDocumentDialog } from './move-document-dialog';
|
||||
import { RestoreDocumentDialog } from './restore-document-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
@ -63,7 +61,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
@ -79,7 +76,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||
const isDeletedDocument = row.deletedAt !== null;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
@ -185,23 +181,13 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
Void
|
||||
</DropdownMenuItem> */}
|
||||
|
||||
{isDeletedDocument ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setRestoreDialogOpen(true)}
|
||||
disabled={Boolean(!canManageDocument)}
|
||||
>
|
||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{canManageDocument ? 'Delete' : 'Hide'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Share</Trans>
|
||||
@ -253,16 +239,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
onOpenChange={setMoveDialogOpen}
|
||||
/>
|
||||
|
||||
<RestoreDocumentDialog
|
||||
id={row.id}
|
||||
status={row.status}
|
||||
documentTitle={row.title}
|
||||
open={isRestoreDialogOpen}
|
||||
onOpenChange={setRestoreDialogOpen}
|
||||
teamId={team?.id}
|
||||
canManageDocument={canManageDocument}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={row.id}
|
||||
|
||||
@ -78,7 +78,7 @@ export const DocumentsDataTable = ({
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
|
||||
@ -47,8 +47,6 @@ export const DeleteDocumentDialog = ({
|
||||
const { refreshLimits } = useLimits();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const deleteMessage = msg`delete`;
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||
|
||||
@ -89,7 +87,7 @@ export const DeleteDocumentDialog = ({
|
||||
|
||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
setIsDeleteEnabled(event.target.value === _(deleteMessage));
|
||||
setIsDeleteEnabled(event.target.value === _(msg`delete`));
|
||||
};
|
||||
|
||||
return (
|
||||
@ -183,7 +181,7 @@ export const DeleteDocumentDialog = ({
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
|
||||
placeholder={_(msg`Type 'delete' to confirm`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -4,10 +4,10 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats-new';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats-new';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team, TeamEmail, TeamMemberRole } from '@documenso/prisma/client';
|
||||
@ -35,7 +35,7 @@ export interface DocumentsPageViewProps {
|
||||
senderIds?: string;
|
||||
search?: string;
|
||||
};
|
||||
team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
|
||||
team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
|
||||
}
|
||||
|
||||
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
|
||||
@ -50,14 +50,25 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
||||
const currentTeam = team
|
||||
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
|
||||
: undefined;
|
||||
const currentTeamMemberRole = team?.currentTeamMember?.role;
|
||||
|
||||
const getStatOptions: GetStatsInput = {
|
||||
user,
|
||||
period,
|
||||
team,
|
||||
search,
|
||||
};
|
||||
|
||||
if (team) {
|
||||
getStatOptions.team = {
|
||||
teamId: team.id,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
senderIds,
|
||||
currentTeamMemberRole,
|
||||
currentUserEmail: user.email,
|
||||
userId: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await getStats(getStatOptions);
|
||||
|
||||
const results = await findDocuments({
|
||||
@ -117,7 +128,6 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.BIN,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Bird, CheckCircle2, Trash } from 'lucide-react';
|
||||
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
@ -30,11 +30,6 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
|
||||
icon: Bird,
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.BIN, () => ({
|
||||
title: msg`No documents in the bin`,
|
||||
message: msg`There are no documents in the bin.`,
|
||||
icon: Trash,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: msg`Nothing to do`,
|
||||
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
|
||||
@ -47,6 +42,7 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||
data-testid="empty-document-state"
|
||||
>
|
||||
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">{_(title)}</h3>
|
||||
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@documenso/ui/primitives/alert-dialog';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type RestoreDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
teamId?: number;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
export function RestoreDocumentDialog({
|
||||
id,
|
||||
teamId,
|
||||
open,
|
||||
onOpenChange,
|
||||
documentTitle,
|
||||
canManageDocument,
|
||||
}: RestoreDocumentDialogProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: restoreDocument, isLoading } =
|
||||
trpcReact.document.restoreDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
toast({
|
||||
title: 'Document restored',
|
||||
description: `"${documentTitle}" has been successfully restored`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onRestore = async () => {
|
||||
try {
|
||||
await restoreDocument({ id, teamId });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This document could not be restored at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You are about to restore the document <strong>"{documentTitle}"</strong>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={onRestore}
|
||||
disabled={!canManageDocument}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@ -12,10 +12,9 @@ import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
|
||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -64,7 +63,7 @@ export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButt
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
{children || <Trans>Manage Subscription</Trans>}
|
||||
<Trans>Manage Subscription</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -68,74 +68,60 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
<Trans>
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Todo: Translation */}
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
<Trans>
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
{subscriptionProduct ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{/* Todo: Translation */}
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
{subscriptionProduct ? (
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
which is set to{' '}
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</span>
|
||||
end on{' '}
|
||||
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>You currently have an active plan</span>
|
||||
)}
|
||||
|
||||
{subscription.periodEnd && (
|
||||
<span>
|
||||
{' '}
|
||||
which is set to{' '}
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<span>
|
||||
end on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
automatically renew on{' '}
|
||||
<span className="font-semibold">
|
||||
{i18n.date(subscription.periodEnd)}.
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
automatically renew on{' '}
|
||||
<span className="font-semibold">{i18n.date(subscription.periodEnd)}.</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<p>
|
||||
<Trans>
|
||||
Your current plan is past due. Please update your payment information.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<BillingPortalButton>
|
||||
<Trans>Manage billing</Trans>
|
||||
</BillingPortalButton>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<p>
|
||||
<Trans>
|
||||
Your current plan is past due. Please update your payment information.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
@ -141,23 +141,6 @@ export const EditTemplateForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTypedSignature } =
|
||||
trpc.template.updateTemplateTypedSignatureSettings.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (newData) => {
|
||||
utils.template.getTemplateWithDetailsById.setData(
|
||||
{
|
||||
id: initialTemplate.id,
|
||||
},
|
||||
(oldData) => ({
|
||||
...(oldData || initialTemplate),
|
||||
...newData,
|
||||
id: Number(newData.id),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||
try {
|
||||
await updateTemplateSettings({
|
||||
@ -228,12 +211,6 @@ export const EditTemplateForm = ({
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
await updateTypedSignature({
|
||||
templateId: template.id,
|
||||
teamId: team?.id,
|
||||
typedSignatureEnabled: data.typedSignatureEnabled,
|
||||
});
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
@ -248,13 +225,14 @@ export const EditTemplateForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
router.push(templateRootPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while adding fields.`),
|
||||
description: _(msg`An error occurred while adding signers.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -323,7 +301,6 @@ export const EditTemplateForm = ({
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
teamId={team?.id}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
|
||||
@ -63,10 +63,7 @@ export const TemplateEditPageView = async ({ params, team }: TemplateEditPageVie
|
||||
<Trans>Template</Trans>
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
|
||||
@ -73,6 +73,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
const mockedDocumentMeta = templateMeta
|
||||
? {
|
||||
typedSignatureEnabled: false,
|
||||
...templateMeta,
|
||||
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||
documentId: 0,
|
||||
@ -88,10 +89,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
@ -157,7 +155,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm">
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
||||
<Trans>Manage and view template</Trans>
|
||||
</p>
|
||||
|
||||
|
||||
@ -209,19 +209,11 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
|
||||
}}
|
||||
>
|
||||
{signature.Signature?.signatureImageAsBase64 && (
|
||||
<img
|
||||
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||
alt="Signature"
|
||||
className="max-h-12 max-w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{signature.Signature?.typedSignature && (
|
||||
<p className="font-signature text-center text-sm">
|
||||
{signature.Signature?.typedSignature}
|
||||
</p>
|
||||
)}
|
||||
<img
|
||||
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||
alt="Signature"
|
||||
className="max-h-12 max-w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
|
||||
@ -12,6 +12,7 @@ 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';
|
||||
@ -71,11 +72,8 @@ export default async function TemplatesDirectPage({ params }: TemplatesDirectPag
|
||||
user={user}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
<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">
|
||||
|
||||
@ -102,9 +102,9 @@ export const SignDirectTemplateForm = ({
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null,
|
||||
typedSignature: value.value.startsWith('data:') ? null : value.value,
|
||||
} satisfies Signature;
|
||||
signatureImageAsBase64: value.value,
|
||||
typedSignature: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
|
||||
@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Plural, Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { Form } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
|
||||
const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
|
||||
FieldType.NAME,
|
||||
FieldType.INITIALS,
|
||||
FieldType.EMAIL,
|
||||
FieldType.DATE,
|
||||
];
|
||||
|
||||
// The action auth types that are not allowed to be auto signed
|
||||
//
|
||||
// Reasoning: If the action auth is a passkey or 2FA, it's likely that the owner of the document
|
||||
// intends on having the user manually sign due to the additional security measures employed for
|
||||
// other field types.
|
||||
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
];
|
||||
|
||||
// The threshold for the number of fields that could be autosigned before displaying the dialog
|
||||
//
|
||||
// Reasoning: If there aren't that many fields, it's likely going to be easier to manually sign each one
|
||||
// while for larger documents with many fields it will be beneficial to sign away the boilerplate fields.
|
||||
const AUTO_SIGN_THRESHOLD = 5;
|
||||
|
||||
export type AutoSignProps = {
|
||||
recipient: Pick<Recipient, 'id' | 'token'>;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
export const AutoSign = ({ recipient, fields }: AutoSignProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { email, fullName } = useRequiredSigningContext();
|
||||
const { derivedRecipientActionAuth } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
||||
|
||||
const autoSignableFields = fields.filter((field) => {
|
||||
if (field.inserted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NAME && !fullName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.INITIALS && !fullName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (field.type === FieldType.EMAIL && !email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(
|
||||
derivedRecipientActionAuth ?? '',
|
||||
);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const results = await Promise.allSettled(
|
||||
autoSignableFields.map(async (field) => {
|
||||
const value = match(field.type)
|
||||
.with(FieldType.NAME, () => fullName)
|
||||
.with(FieldType.INITIALS, () => extractInitials(fullName))
|
||||
.with(FieldType.EMAIL, () => email)
|
||||
.with(FieldType.DATE, () => new Date().toISOString())
|
||||
.otherwise(() => '');
|
||||
|
||||
const authOptions = match(derivedRecipientActionAuth)
|
||||
.with(DocumentAuth.ACCOUNT, () => ({
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
}))
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||
type: DocumentAuth.EXPLICIT_NONE,
|
||||
}))
|
||||
.with(null, () => undefined)
|
||||
.with(
|
||||
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH),
|
||||
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
|
||||
() => 'NOT_SUPPORTED' as const,
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
if (authOptions === 'NOT_SUPPORTED') {
|
||||
throw new Error('Action auth is not supported for auto signing');
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
throw new Error('No value to sign');
|
||||
}
|
||||
|
||||
return await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value,
|
||||
isBase64: false,
|
||||
authOptions,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(
|
||||
msg`An error occurred while auto-signing the document, some fields may not be signed. Please review and manually sign any remaining fields.`,
|
||||
),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
|
||||
setOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
unsafe_useEffectOnce(() => {
|
||||
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Automatically sign fields</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<p>
|
||||
<Trans>
|
||||
When you sign a document, we can automatically fill in and sign the following fields
|
||||
using information that has already been provided. You can also manually sign or remove
|
||||
any automatically signed fields afterwards if you desire.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 flex list-inside list-disc flex-col gap-y-0.5">
|
||||
{AUTO_SIGNABLE_FIELD_TYPES.map((fieldType) => (
|
||||
<li key={fieldType}>
|
||||
<Trans>{_(FRIENDLY_FIELD_TYPE[fieldType as FieldType])}</Trans>
|
||||
<span className="pl-2 text-sm">
|
||||
(
|
||||
<Plural
|
||||
value={autoSignableFields.filter((f) => f.type === fieldType).length}
|
||||
one="1 matching field"
|
||||
other="# matching fields"
|
||||
/>
|
||||
)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<SigningDisclosure className="mt-4" />
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogFooter className="flex w-full flex-1 flex-nowrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-w-[6rem]"
|
||||
loading={form.formState.isSubmitting || isPending}
|
||||
disabled={!autoSignableFields.length}
|
||||
>
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -24,6 +24,8 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { SigningAuthPageView } from '../signing-auth-page';
|
||||
import { ClaimAccount } from './claim-account';
|
||||
import { DocumentPreviewButton } from './document-preview-button';
|
||||
@ -59,6 +61,8 @@ export default async function CompletedSigningPage({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
@ -114,9 +118,7 @@ export default async function CompletedSigningPage({
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{document.title}
|
||||
</span>
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
{/* Card with recipient */}
|
||||
|
||||
@ -144,13 +144,13 @@ export const DateField = ({
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Date</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
{localDateString}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -178,7 +178,7 @@ export const DropdownField = ({
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200 ">
|
||||
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
|
||||
<Select value={localChoice} onValueChange={handleSelectItem}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
@ -190,7 +190,7 @@ export const DropdownField = ({
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
className="text-[clamp(0.425rem,25cqw,0.825rem)]"
|
||||
className="text-[clamp(0.625rem,1cqw,0.825rem)]"
|
||||
placeholder={`${_(msg`Select`)}`}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@ -206,7 +206,7 @@ export const DropdownField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -122,13 +122,13 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Email</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -128,13 +128,13 @@ export const InitialsField = ({
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Initials</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -172,7 +172,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -252,15 +252,14 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-x-1">
|
||||
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
|
||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
|
||||
<span className="flex items-center justify-center gap-x-1 text-sm">
|
||||
<Hash className="h-4 w-4" /> {fieldDisplayName}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -99,10 +99,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
|
||||
const { documentMeta } = document;
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||
return redirect(`/sign/${token}/rejected`);
|
||||
}
|
||||
|
||||
if (
|
||||
document.status === DocumentStatus.COMPLETED ||
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
|
||||
export type SigningContextValue = {
|
||||
fullName: string;
|
||||
@ -44,12 +44,6 @@ export const SigningProvider = ({
|
||||
const [email, setEmail] = useState(initialEmail || '');
|
||||
const [signature, setSignature] = useState(initialSignature || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSignature) {
|
||||
setSignature(initialSignature);
|
||||
}
|
||||
}, [initialSignature]);
|
||||
|
||||
return (
|
||||
<SigningContext.Provider
|
||||
value={{
|
||||
|
||||
@ -1,170 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Document } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZRejectDocumentFormSchema = z.object({
|
||||
reason: z
|
||||
.string()
|
||||
.min(5, msg`Please provide a reason`)
|
||||
.max(500, msg`Reason must be less than 500 characters`),
|
||||
});
|
||||
|
||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||
|
||||
export interface RejectDocumentDialogProps {
|
||||
document: Pick<Document, 'id'>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: rejectDocumentWithToken } =
|
||||
trpc.recipient.rejectDocumentWithToken.useMutation();
|
||||
|
||||
const form = useForm<TRejectDocumentFormSchema>({
|
||||
resolver: zodResolver(ZRejectDocumentFormSchema),
|
||||
defaultValues: {
|
||||
reason: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => {
|
||||
try {
|
||||
// TODO: Add trpc mutation here
|
||||
await rejectDocumentWithToken({
|
||||
documentId: document.id,
|
||||
token,
|
||||
reason,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Document rejected',
|
||||
description: 'The document has been successfully rejected.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
|
||||
router.push(`/sign/${token}/rejected`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while rejecting the document. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams?.get('reject') === 'true') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Reject Document</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Are you sure you want to reject this document? This action cannot be undone.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onRejectDocument)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={4}
|
||||
placeholder="Please provide a reason for rejecting this document"
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={!form.formState.isValid}
|
||||
>
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,110 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { XCircle } from 'lucide-react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
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 { FieldType } from '@documenso/prisma/client';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { SigningAuthPageView } from '../signing-auth-page';
|
||||
|
||||
export type RejectedSigningPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function RejectedSigningPage({ params: { token } }: RejectedSigningPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
|
||||
const document = await getDocumentAndSenderByToken({
|
||||
token,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!recipient) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <SigningAuthPageView email={recipient.email} />;
|
||||
}
|
||||
|
||||
const recipientName =
|
||||
recipient.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
recipient.email;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<XCircle className="text-destructive h-10 w-10" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="text-destructive mt-4 flex items-center text-center text-sm">
|
||||
<Trans>You have rejected this document</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||
<Trans>
|
||||
The document owner has been notified of your decision. They may contact you with further
|
||||
instructions if necessary.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||
<Trans>No further action is required from you at this time.</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link href={`/`}>Return Home</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -14,6 +14,7 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
@ -35,7 +36,7 @@ export const SignDialog = ({
|
||||
disabled = false,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
|
||||
const truncatedTitle = truncateTitle(documentTitle);
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
@ -74,13 +75,7 @@ export const SignDialog = ({
|
||||
{role === RecipientRole.VIEWER && (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
You are about to complete viewing "{truncatedTitle}".
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
@ -88,13 +83,7 @@ export const SignDialog = ({
|
||||
{role === RecipientRole.SIGNER && (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
You are about to complete signing "{truncatedTitle}".
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
@ -102,13 +91,7 @@ export const SignDialog = ({
|
||||
{role === RecipientRole.APPROVER && (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
You are about to complete approving "{truncatedTitle}".
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useLayoutEffect, useMemo, useRef, useState, useTransition } from 'react';
|
||||
import { useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
@ -51,10 +51,6 @@ export const SignatureField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signatureRef = useRef<HTMLParagraphElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState(2);
|
||||
|
||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||
useRequiredSigningContext();
|
||||
|
||||
@ -112,7 +108,6 @@ export const SignatureField = ({
|
||||
actionTarget: field.type,
|
||||
});
|
||||
};
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
|
||||
try {
|
||||
const value = signature || providedSignature;
|
||||
@ -122,23 +117,11 @@ export const SignatureField = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const isTypedSignature = !value.startsWith('data:image');
|
||||
|
||||
if (isTypedSignature && !typedSignatureEnabled) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value,
|
||||
isBase64: !isTypedSignature,
|
||||
isBase64: true,
|
||||
authOptions,
|
||||
};
|
||||
|
||||
@ -193,41 +176,6 @@ export const SignatureField = ({
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!signatureRef.current || !containerRef.current || !signature?.typedSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adjustTextSize = () => {
|
||||
const container = containerRef.current;
|
||||
const text = signatureRef.current;
|
||||
|
||||
if (!container || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
let size = 2;
|
||||
text.style.fontSize = `${size}rem`;
|
||||
|
||||
while (
|
||||
(text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) &&
|
||||
size > 0.8
|
||||
) {
|
||||
size -= 0.1;
|
||||
text.style.fontSize = `${size}rem`;
|
||||
}
|
||||
|
||||
setFontSize(size);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(adjustTextSize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
adjustTextSize();
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [signature?.typedSignature]);
|
||||
|
||||
return (
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
@ -243,7 +191,7 @@ export const SignatureField = ({
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground text-xl duration-200 group-hover:text-yellow-300">
|
||||
<Trans>Signature</Trans>
|
||||
</p>
|
||||
)}
|
||||
@ -257,15 +205,10 @@ export const SignatureField = ({
|
||||
)}
|
||||
|
||||
{state === 'signed-text' && (
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
||||
<p
|
||||
ref={signatureRef}
|
||||
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
|
||||
style={{ fontSize: `${fontSize}rem` }}
|
||||
>
|
||||
{signature?.typedSignature}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-signature text-muted-foreground dark:text-background text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
|
||||
{signature?.typedSignature}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>
|
||||
|
||||
@ -22,7 +22,6 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
|
||||
import { AutoSign } from './auto-sign';
|
||||
import { CheckboxField } from './checkbox-field';
|
||||
import { DateField } from './date-field';
|
||||
import { DropdownField } from './dropdown-field';
|
||||
@ -32,7 +31,6 @@ import { InitialsField } from './initials-field';
|
||||
import { NameField } from './name-field';
|
||||
import { NumberField } from './number-field';
|
||||
import { RadioField } from './radio-field';
|
||||
import { RejectDocumentDialog } from './reject-document-dialog';
|
||||
import { SignatureField } from './signature-field';
|
||||
import { TextField } from './text-field';
|
||||
|
||||
@ -55,40 +53,33 @@ export const SigningPageView = ({
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div>
|
||||
<p
|
||||
className="text-muted-foreground truncate"
|
||||
title={document.User.name ? document.User.name : ''}
|
||||
>
|
||||
{document.User.name}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<Trans>({document.User.email}) has invited you to view this document</Trans>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<Trans>({document.User.email}) has invited you to sign this document</Trans>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<Trans>({document.User.email}) has invited you to approve this document</Trans>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RejectDocumentDialog document={document} token={recipient.token} />
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<p
|
||||
className="text-muted-foreground truncate"
|
||||
title={document.User.name ? document.User.name : ''}
|
||||
>
|
||||
{document.User.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<Trans>({document.User.email}) has invited you to view this document</Trans>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<Trans>({document.User.email}) has invited you to sign this document</Trans>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<Trans>({document.User.email}) has invited you to approve this document</Trans>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
@ -117,8 +108,6 @@ export const SigningPageView = ({
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
<AutoSign recipient={recipient} fields={fields} />
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
|
||||
@ -252,16 +252,14 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-x-1">
|
||||
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
|
||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||
{fieldDisplayName || <Trans>Text</Trans>}
|
||||
</span>
|
||||
<Type />
|
||||
{fieldDisplayName || <Trans>Text</Trans>}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 15) + '...'}
|
||||
|
||||
@ -52,7 +52,13 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
|
||||
|
||||
<AvatarImageForm className="mb-8" team={team} user={session.user} />
|
||||
|
||||
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
<UpdateTeamForm
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
teamUrl={team.url}
|
||||
documentVisibility={team.teamGlobalSettings?.documentVisibility}
|
||||
includeSenderDetails={team.teamGlobalSettings?.includeSenderDetails}
|
||||
/>
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
|
||||
@ -39,8 +39,6 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
includeSenderDetails: z.boolean(),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
includeSigningCertificate: z.boolean(),
|
||||
});
|
||||
|
||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||
@ -70,8 +68,6 @@ export const TeamDocumentPreferencesForm = ({
|
||||
? settings?.documentLanguage
|
||||
: 'en',
|
||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
|
||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||
},
|
||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||
});
|
||||
@ -80,13 +76,7 @@ export const TeamDocumentPreferencesForm = ({
|
||||
|
||||
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
typedSignatureEnabled,
|
||||
} = data;
|
||||
const { documentVisibility, documentLanguage, includeSenderDetails } = data;
|
||||
|
||||
await updateTeamDocumentPreferences({
|
||||
teamId: team.id,
|
||||
@ -94,8 +84,6 @@ export const TeamDocumentPreferencesForm = ({
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
typedSignatureEnabled,
|
||||
includeSigningCertificate,
|
||||
},
|
||||
});
|
||||
|
||||
@ -117,7 +105,7 @@ export const TeamDocumentPreferencesForm = ({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-6"
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
@ -217,14 +205,10 @@ export const TeamDocumentPreferencesForm = ({
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
||||
{includeSenderDetails ? (
|
||||
<Trans>
|
||||
"{placeholderEmail}" on behalf of "{team.name}" has invited you to sign
|
||||
"example document".
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>"{team.name}" has invited you to sign "example document".</Trans>
|
||||
)}
|
||||
{includeSenderDetails
|
||||
? _(msg`"${placeholderEmail}" on behalf of "${team.name}" has invited you to sign "example
|
||||
document".`)
|
||||
: _(msg`"${team.name}" has invited you to sign "example document".`)}
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
@ -239,67 +223,6 @@ export const TeamDocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="typedSignatureEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enable Typed Signature</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the recipients can sign the documents using a typed signature.
|
||||
Enable or disable the typed signature globally.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSigningCertificate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Include the Signing Certificate in the Document</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the signing certificate will be included in the document when
|
||||
it is downloaded. The signing certificate can still be downloaded from the logs
|
||||
page separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZCssVarsSchema } from './css-vars';
|
||||
|
||||
export const ZBaseEmbedDataSchema = z.object({
|
||||
darkModeDisabled: z.boolean().optional().default(false),
|
||||
css: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
cssVars: ZCssVarsSchema.optional().default({}),
|
||||
});
|
||||
|
||||
@ -10,7 +10,6 @@ export type EmbedDocumentCompletedPageProps = {
|
||||
};
|
||||
|
||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||
console.log({ signature });
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
import { colord } from 'colord';
|
||||
import { toSnakeCase } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCssVarsSchema = z
|
||||
.object({
|
||||
background: z.string().optional().describe('Base background color'),
|
||||
foreground: z.string().optional().describe('Base text color'),
|
||||
muted: z.string().optional().describe('Muted/subtle background color'),
|
||||
mutedForeground: z.string().optional().describe('Muted/subtle text color'),
|
||||
popover: z.string().optional().describe('Popover/dropdown background color'),
|
||||
popoverForeground: z.string().optional().describe('Popover/dropdown text color'),
|
||||
card: z.string().optional().describe('Card background color'),
|
||||
cardBorder: z.string().optional().describe('Card border color'),
|
||||
cardBorderTint: z.string().optional().describe('Card border tint/highlight color'),
|
||||
cardForeground: z.string().optional().describe('Card text color'),
|
||||
fieldCard: z.string().optional().describe('Field card background color'),
|
||||
fieldCardBorder: z.string().optional().describe('Field card border color'),
|
||||
fieldCardForeground: z.string().optional().describe('Field card text color'),
|
||||
widget: z.string().optional().describe('Widget background color'),
|
||||
widgetForeground: z.string().optional().describe('Widget text color'),
|
||||
border: z.string().optional().describe('Default border color'),
|
||||
input: z.string().optional().describe('Input field border color'),
|
||||
primary: z.string().optional().describe('Primary action/button color'),
|
||||
primaryForeground: z.string().optional().describe('Primary action/button text color'),
|
||||
secondary: z.string().optional().describe('Secondary action/button color'),
|
||||
secondaryForeground: z.string().optional().describe('Secondary action/button text color'),
|
||||
accent: z.string().optional().describe('Accent/highlight color'),
|
||||
accentForeground: z.string().optional().describe('Accent/highlight text color'),
|
||||
destructive: z.string().optional().describe('Destructive/danger action color'),
|
||||
destructiveForeground: z.string().optional().describe('Destructive/danger text color'),
|
||||
ring: z.string().optional().describe('Focus ring color'),
|
||||
radius: z.string().optional().describe('Border radius size in REM units'),
|
||||
warning: z.string().optional().describe('Warning/alert color'),
|
||||
})
|
||||
.describe('Custom CSS variables for theming');
|
||||
|
||||
export type TCssVarsSchema = z.infer<typeof ZCssVarsSchema>;
|
||||
|
||||
export const toNativeCssVars = (vars: TCssVarsSchema) => {
|
||||
const cssVars: Record<string, string> = {};
|
||||
|
||||
const { radius, ...colorVars } = vars;
|
||||
|
||||
for (const [key, value] of Object.entries(colorVars)) {
|
||||
if (value) {
|
||||
const color = colord(value);
|
||||
const { h, s, l } = color.toHsl();
|
||||
|
||||
cssVars[`--${toSnakeCase(key)}`] = `${h} ${s} ${l}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (radius) {
|
||||
cssVars[`--radius`] = `${radius}`;
|
||||
}
|
||||
|
||||
return cssVars;
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
@ -14,7 +14,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -38,7 +38,6 @@ import { Logo } from '~/components/branding/logo';
|
||||
import { EmbedClientLoading } from '../../client-loading';
|
||||
import { EmbedDocumentCompleted } from '../../completed';
|
||||
import { EmbedDocumentFields } from '../../document-fields';
|
||||
import { injectCss } from '../../util';
|
||||
import { ZDirectTemplateEmbedDataSchema } from './schema';
|
||||
|
||||
export type EmbedDirectTemplateClientPageProps = {
|
||||
@ -48,8 +47,6 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
@ -59,8 +56,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -113,9 +108,9 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
||||
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
||||
} satisfies Signature;
|
||||
signatureImageAsBase64: payload.value,
|
||||
typedSignature: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
@ -254,7 +249,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
@ -269,17 +264,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
setFullName(data.name);
|
||||
setIsNameLocked(!!data.lockName);
|
||||
}
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@ -312,8 +296,8 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
fieldId: 1,
|
||||
recipientId: 1,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
||||
typedSignature: null,
|
||||
signatureImageAsBase64: signature,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -468,12 +452,10 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,11 +2,8 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
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';
|
||||
@ -54,14 +51,6 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
isDocumentPlatform(template),
|
||||
isUserEnterprise({
|
||||
userId: template.userId,
|
||||
teamId: template.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
||||
.with(null, () => true)
|
||||
@ -83,12 +72,6 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
|
||||
const fields = template.Field.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
const team = template.teamId
|
||||
? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null)
|
||||
: null;
|
||||
|
||||
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
|
||||
|
||||
return (
|
||||
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
||||
<DocumentAuthProvider
|
||||
@ -103,8 +86,6 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
@ -58,7 +58,6 @@ export const EmbedDocumentFields = ({
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -28,7 +28,6 @@ import { Logo } from '~/components/branding/logo';
|
||||
import { EmbedClientLoading } from '../../client-loading';
|
||||
import { EmbedDocumentCompleted } from '../../completed';
|
||||
import { EmbedDocumentFields } from '../../document-fields';
|
||||
import { injectCss } from '../../util';
|
||||
import { ZSignDocumentEmbedDataSchema } from './schema';
|
||||
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
@ -39,8 +38,6 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
@ -51,8 +48,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
fields,
|
||||
metadata,
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -136,7 +131,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
@ -149,17 +144,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@ -192,8 +176,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
fieldId: 1,
|
||||
recipientId: 1,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
||||
typedSignature: null,
|
||||
signatureImageAsBase64: signature,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -218,7 +202,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
@ -341,12 +325,10 @@ export const EmbedSignDocumentClientPage = ({
|
||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,14 +2,11 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-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 { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
@ -59,14 +56,6 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
return <EmbedPaywall />;
|
||||
}
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
isDocumentPlatform(document),
|
||||
isUserEnterprise({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
@ -85,12 +74,6 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
);
|
||||
}
|
||||
|
||||
const team = document.teamId
|
||||
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
|
||||
: null;
|
||||
|
||||
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
|
||||
|
||||
return (
|
||||
<SigningProvider
|
||||
email={recipient.email}
|
||||
@ -110,8 +93,6 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
fields={fields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import { type TCssVarsSchema, toNativeCssVars } from './css-vars';
|
||||
|
||||
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
|
||||
const { css, cssVars } = options;
|
||||
|
||||
if (css) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = css;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
if (cssVars) {
|
||||
const nativeVars = toNativeCssVars(cssVars);
|
||||
|
||||
for (const [key, value] of Object.entries(nativeVars)) {
|
||||
document.documentElement.style.setProperty(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
const ZIndexes: { [key: string]: string } = {
|
||||
@ -13,7 +12,7 @@ export type StackAvatarProps = {
|
||||
first?: boolean;
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: RecipientStatusType;
|
||||
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
@ -26,21 +25,18 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case RecipientStatusType.UNSIGNED:
|
||||
case 'unsigned':
|
||||
classes = 'bg-dawn-200 text-dawn-900';
|
||||
break;
|
||||
case RecipientStatusType.OPENED:
|
||||
case 'opened':
|
||||
classes = 'bg-yellow-200 text-yellow-700';
|
||||
break;
|
||||
case RecipientStatusType.WAITING:
|
||||
case 'waiting':
|
||||
classes = 'bg-water text-water-700';
|
||||
break;
|
||||
case RecipientStatusType.COMPLETED:
|
||||
case 'completed':
|
||||
classes = 'bg-documenso-200 text-documenso-800';
|
||||
break;
|
||||
case RecipientStatusType.REJECTED:
|
||||
classes = 'bg-red-200 text-red-800';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { type DocumentStatus, type Recipient } from '@documenso/prisma/client';
|
||||
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
@ -46,22 +46,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
|
||||
);
|
||||
|
||||
const rejectedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const otherRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
return [
|
||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||
...otherRecipients.sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
}),
|
||||
];
|
||||
}, [recipients]);
|
||||
const sortedRecipients = useMemo(() => recipients.sort((a, b) => a.id - b.id), [recipients]);
|
||||
|
||||
return (
|
||||
<PopoverHover
|
||||
@ -95,30 +80,6 @@ export const StackAvatarsWithTooltip = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rejectedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
|
||||
@ -6,14 +6,22 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import {
|
||||
DocumentVisibilitySelect,
|
||||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -29,17 +37,29 @@ export type UpdateTeamDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
documentVisibility?: DocumentVisibility;
|
||||
includeSenderDetails?: boolean;
|
||||
};
|
||||
|
||||
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
documentVisibility: true,
|
||||
includeSenderDetails: true,
|
||||
});
|
||||
|
||||
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
|
||||
|
||||
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
|
||||
export const UpdateTeamForm = ({
|
||||
teamId,
|
||||
teamName,
|
||||
teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: UpdateTeamDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const email = session?.user?.email;
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -48,17 +68,36 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
defaultValues: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
||||
const includeSenderDetailsCheck = form.watch('includeSenderDetails');
|
||||
|
||||
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
|
||||
const mapVisibilityToRole = (visibility: DocumentVisibility): DocumentVisibility =>
|
||||
match(visibility)
|
||||
.with(DocumentVisibility.ADMIN, () => DocumentVisibility.ADMIN)
|
||||
.with(DocumentVisibility.MANAGER_AND_ABOVE, () => DocumentVisibility.MANAGER_AND_ABOVE)
|
||||
.otherwise(() => DocumentVisibility.EVERYONE);
|
||||
|
||||
const currentVisibilityRole = mapVisibilityToRole(
|
||||
documentVisibility ?? DocumentVisibility.EVERYONE,
|
||||
);
|
||||
const onFormSubmit = async ({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
}: TUpdateTeamFormSchema) => {
|
||||
try {
|
||||
await updateTeam({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
@ -72,6 +111,8 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
form.reset({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
});
|
||||
|
||||
if (url !== teamUrl) {
|
||||
@ -145,6 +186,68 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="mt-4 flex flex-row items-center">
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
<DocumentVisibilityTooltip />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DocumentVisibilitySelect
|
||||
currentMemberRole={currentVisibilityRole}
|
||||
isTeamSettings={true}
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="mt-6 flex flex-row items-center gap-4">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5"
|
||||
checkClassName="text-white"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
{includeSenderDetailsCheck ? (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>
|
||||
"{email}" on behalf of "{teamName}" has invited you to sign "example
|
||||
document".
|
||||
</Trans>
|
||||
</blockquote>
|
||||
) : (
|
||||
<blockquote className="text-foreground/50 text-xs italic">
|
||||
<Trans>"{teamUrl}" has invited you to sign "example document".</Trans>
|
||||
</blockquote>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
|
||||
@ -167,10 +167,8 @@ export const DocumentHistorySheet = ({
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
|
||||
() => null,
|
||||
|
||||
@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckCircle2, Clock, File, TrashIcon } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
@ -47,12 +47,6 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
||||
labelExtended: msg`Document All`,
|
||||
color: 'text-muted-foreground',
|
||||
},
|
||||
BIN: {
|
||||
label: msg`Bin`,
|
||||
labelExtended: msg`Document Bin`,
|
||||
icon: TrashIcon,
|
||||
color: 'text-red-500 dark:text-red-200',
|
||||
},
|
||||
};
|
||||
|
||||
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
|
||||
@ -138,7 +138,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
containerClassName={cn('rounded-lg border bg-background')}
|
||||
defaultValue={user.signature ?? undefined}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
allowTypedSignature={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
796
package-lock.json
generated
796
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.8.1-rc.1",
|
||||
"version": "1.8.0-rc.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
@ -52,7 +52,7 @@
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "1.43.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
},
|
||||
|
||||
@ -168,9 +168,6 @@ export const ApiContractV1 = c.router(
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Send a document for signing',
|
||||
// I'm aware this should be in the variable itself, which it is, however it's difficult for users to find in our current UI.
|
||||
description:
|
||||
'Notes\n\n`sendEmail` - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true',
|
||||
},
|
||||
|
||||
resendDocument: {
|
||||
|
||||
@ -302,7 +302,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
redirectUrl: body.meta.redirectUrl,
|
||||
signingOrder: body.meta.signingOrder,
|
||||
language: body.meta.language,
|
||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import '@documenso/lib/constants/time-zones';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { ZUrlSchema } from '@documenso/lib/schemas/common';
|
||||
import {
|
||||
@ -13,7 +14,6 @@ import {
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentDistributionMethod,
|
||||
DocumentSigningOrder,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
@ -67,10 +67,7 @@ export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocume
|
||||
|
||||
export const ZSendDocumentForSigningMutationSchema = z
|
||||
.object({
|
||||
sendEmail: z.boolean().optional().default(true).openapi({
|
||||
description:
|
||||
'Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links.',
|
||||
}),
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
})
|
||||
.or(z.literal('').transform(() => ({ sendEmail: true })));
|
||||
|
||||
@ -132,7 +129,6 @@ export const ZCreateDocumentMutationSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||
})
|
||||
.partial(),
|
||||
authOptions: z
|
||||
@ -227,14 +223,14 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
externalId: z.string().optional(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
email: z.string().email().min(1),
|
||||
signingOrder: z.number().nullish(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
@ -253,10 +249,8 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
timezone: z.string(),
|
||||
dateFormat: z.string(),
|
||||
redirectUrl: ZUrlSchema,
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
|
||||
@ -1,271 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe('Signing Certificate Tests', () => {
|
||||
test('individual document should always include signing certificate', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['signer@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of recipient.Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const pdfDoc = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
expect(pdfDoc.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
|
||||
});
|
||||
|
||||
test('team document with signing certificate enabled should include certificate', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: team.owner,
|
||||
recipients: ['signer@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.teamGlobalSettings.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of recipient.Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount() + 1); // Original + Certificate
|
||||
});
|
||||
|
||||
test('team document with signing certificate disabled should not include certificate', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: team.owner,
|
||||
recipients: ['signer@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.teamGlobalSettings.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
includeSigningCertificate: false,
|
||||
},
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
|
||||
const originalPdf = await PDFDocument.load(documentData);
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
// Sign the document
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of recipient.Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
|
||||
await expect(async () => {
|
||||
const { status } = await getDocumentByToken({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
expect(status).toBe(DocumentStatus.COMPLETED);
|
||||
}).toPass();
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
expect(completedPdf.getPageCount()).toBe(originalPdf.getPageCount());
|
||||
});
|
||||
|
||||
test('team can toggle signing certificate setting', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||
});
|
||||
|
||||
// Toggle signing certificate setting
|
||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||
await page.getByRole('button', { name: /Save/ }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(updatedTeam.teamGlobalSettings?.includeSigningCertificate).toBe(false);
|
||||
|
||||
// Toggle the setting back to true
|
||||
await page.getByLabel('Include the Signing Certificate in the Document').click();
|
||||
await page.getByRole('button', { name: /Save/ }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the setting was saved
|
||||
const updatedTeam2 = await prisma.team.findFirstOrThrow({
|
||||
where: { id: team.id },
|
||||
include: { teamGlobalSettings: true },
|
||||
});
|
||||
|
||||
expect(updatedTeam2.teamGlobalSettings?.includeSigningCertificate).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -17,17 +17,19 @@ test('[TEAMS]: update the default document visibility in the team global setting
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
// !: Brittle selector
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Admin' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
await expect(toast.getByText('Success', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
toast.getByText('Your team has been successfully updated.', { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: update the sender details in the team global settings', async ({ page }) => {
|
||||
@ -39,7 +41,7 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings/preferences`,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
const checkbox = page.getByLabel('Send on Behalf of Team');
|
||||
@ -47,11 +49,14 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
|
||||
const toast = page.locator('li[role="status"][data-state="open"]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
await expect(toast.getByText('Success', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
toast.getByText('Your team has been successfully updated.', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
@ -7,17 +7,15 @@
|
||||
"scripts": {
|
||||
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.18.1",
|
||||
"@types/node": "^20.8.2",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/web": "*",
|
||||
"pdf-lib": "^1.17.1"
|
||||
"@documenso/web": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
|
||||
@ -9,7 +9,6 @@ export const getDocumentRelatedPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.REGULAR,
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getPlatformPlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.PLATFORM);
|
||||
};
|
||||
|
||||
export const getPlatformPlanPriceIds = async () => {
|
||||
const prices = await getPlatformPlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
@ -9,7 +9,6 @@ export const getPrimaryAccountPlanPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.REGULAR,
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
|
||||
@ -6,11 +6,7 @@ import { getPricesByPlan } from './get-prices-by-plan';
|
||||
* Returns the Stripe prices of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Subscription } from '@documenso/prisma/client';
|
||||
|
||||
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
||||
|
||||
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
|
||||
|
||||
/**
|
||||
* Whether the user is platform, or has permission to use platform features on
|
||||
* behalf of their team.
|
||||
*
|
||||
* It is assumed that the provided user is part of the provided team.
|
||||
*/
|
||||
export const isDocumentPlatform = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: IsDocumentPlatformOptions): Promise<boolean> => {
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
subscriptions = await prisma.team
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
owner: {
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((team) => team.owner.Subscription);
|
||||
} else {
|
||||
subscriptions = await prisma.user
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
Subscription: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user.Subscription);
|
||||
}
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const platformPlanPriceIds = await getPlatformPlanPriceIds();
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, platformPlanPriceIds);
|
||||
};
|
||||
@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
@ -38,14 +36,6 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
const rejectDocumentLink = useMemo(() => {
|
||||
const url = new URL(signDocumentLink);
|
||||
|
||||
url.searchParams.set('reject', 'true');
|
||||
|
||||
return url.toString();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
@ -89,13 +79,6 @@ export const TemplateDocumentInvite = ({
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg bg-red-500 px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={rejectDocumentLink}
|
||||
>
|
||||
<Trans>Reject Document</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { Button, Heading, Text } from '../components';
|
||||
|
||||
export interface TemplateDocumentRejectedProps {
|
||||
documentName: string;
|
||||
recipientName: string;
|
||||
rejectionReason?: string;
|
||||
documentUrl: string;
|
||||
}
|
||||
|
||||
export function TemplateDocumentRejected({
|
||||
documentName,
|
||||
recipientName: signerName,
|
||||
rejectionReason,
|
||||
documentUrl,
|
||||
}: TemplateDocumentRejectedProps) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Heading className="mb-4 text-center text-2xl font-semibold text-slate-800">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</Heading>
|
||||
|
||||
<Text className="mb-4 text-base">
|
||||
<Trans>
|
||||
{signerName} has rejected the document "{documentName}".
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{rejectionReason && (
|
||||
<Text className="mb-4 text-base text-slate-400">
|
||||
<Trans>Reason for rejection: {rejectionReason}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text className="mb-6 text-base">
|
||||
<Trans>You can view the document and its status by clicking the button below.</Trans>
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
href={documentUrl}
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
>
|
||||
<Trans>View Document</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { Container, Heading, Section, Text } from '../components';
|
||||
|
||||
interface TemplateDocumentRejectionConfirmedProps {
|
||||
recipientName: string;
|
||||
documentName: string;
|
||||
documentOwnerName: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function TemplateDocumentRejectionConfirmed({
|
||||
recipientName,
|
||||
documentName,
|
||||
documentOwnerName,
|
||||
reason,
|
||||
}: TemplateDocumentRejectionConfirmedProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Section>
|
||||
<Heading className="text-2xl font-semibold">
|
||||
<Trans>Rejection Confirmed</Trans>
|
||||
</Heading>
|
||||
|
||||
<Text className="text-primary text-base">
|
||||
<Trans>
|
||||
This email confirms that you have rejected the document{' '}
|
||||
<strong className="font-bold">"{documentName}"</strong> sent by {documentOwnerName}.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{reason && (
|
||||
<Text className="text-base font-medium text-slate-400">
|
||||
<Trans>Rejection reason: {reason}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text className="text-base">
|
||||
<Trans>
|
||||
The document owner has been notified of this rejection. No further action is required
|
||||
from you at this time. The document owner may contact you with any questions regarding
|
||||
this rejection.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateDocumentRejected } from '../template-components/template-document-rejected';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
type DocumentRejectedEmailProps = {
|
||||
recipientName: string;
|
||||
documentName: string;
|
||||
documentUrl: string;
|
||||
rejectionReason: string;
|
||||
assetBaseUrl?: string;
|
||||
};
|
||||
|
||||
export function DocumentRejectedEmail({
|
||||
recipientName,
|
||||
documentName,
|
||||
documentUrl,
|
||||
rejectionReason,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentRejectedEmailProps) {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = _(msg`${recipientName} has rejected the document '${documentName}'`);
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentRejected
|
||||
recipientName={recipientName}
|
||||
documentName={documentName}
|
||||
documentUrl={documentUrl}
|
||||
rejectionReason={rejectionReason}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentRejectedEmail;
|
||||
@ -1,70 +0,0 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateDocumentRejectionConfirmed } from '../template-components/template-document-rejection-confirmed';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentRejectionConfirmedEmailProps = {
|
||||
recipientName: string;
|
||||
documentName: string;
|
||||
documentOwnerName: string;
|
||||
reason: string;
|
||||
assetBaseUrl?: string;
|
||||
};
|
||||
|
||||
export function DocumentRejectionConfirmedEmail({
|
||||
recipientName,
|
||||
documentName,
|
||||
documentOwnerName,
|
||||
reason,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: DocumentRejectionConfirmedEmailProps) {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = _(msg`You have rejected the document '${documentName}'`);
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentRejectionConfirmed
|
||||
recipientName={recipientName}
|
||||
documentName={documentName}
|
||||
documentOwnerName={documentOwnerName}
|
||||
reason={reason}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentRejectionConfirmedEmail;
|
||||
@ -6,7 +6,6 @@ export enum RecipientStatusType {
|
||||
OPENED = 'opened',
|
||||
WAITING = 'waiting',
|
||||
UNSIGNED = 'unsigned',
|
||||
REJECTED = 'rejected',
|
||||
}
|
||||
|
||||
export const getRecipientType = (recipient: Recipient) => {
|
||||
@ -17,10 +16,6 @@ export const getRecipientType = (recipient: Recipient) => {
|
||||
return RecipientStatusType.COMPLETED;
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||
return RecipientStatusType.REJECTED;
|
||||
}
|
||||
|
||||
if (
|
||||
recipient.sendStatus === SendStatus.SENT &&
|
||||
recipient.readStatus === ReadStatus.OPENED &&
|
||||
|
||||
@ -7,6 +7,5 @@ export enum STRIPE_PLAN_TYPE {
|
||||
REGULAR = 'regular',
|
||||
TEAM = 'team',
|
||||
COMMUNITY = 'community',
|
||||
PLATFORM = 'platform',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { JobClient } from './client/client';
|
||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
|
||||
@ -18,7 +17,6 @@ export const jobsClient = new JobClient([
|
||||
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
||||
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
|
||||
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import { formatDocumentsPath } from '../../../utils/teams';
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID = 'send.signing.rejected.emails';
|
||||
|
||||
const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
|
||||
documentId: z.number(),
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION = {
|
||||
id: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
|
||||
name: 'Send Rejection Emails',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
|
||||
schema: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const { documentId, recipientId } = payload;
|
||||
|
||||
const [document, recipient] = await Promise.all([
|
||||
prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
url: true,
|
||||
teamGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
id: recipientId,
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const { documentMeta, team, User: documentOwner } = document;
|
||||
|
||||
const isEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i18n = await getI18nInstance(documentMeta?.language);
|
||||
|
||||
// Send confirmation email to the recipient who rejected
|
||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: document.title,
|
||||
documentOwnerName: document.User.name || document.User.email,
|
||||
reason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(recipientTemplate, { lang: documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(recipientTemplate, {
|
||||
lang: documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
|
||||
// Send notification email to document owner
|
||||
await io.runTask('send-owner-notification-email', async () => {
|
||||
const ownerTemplate = createElement(DocumentRejectedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: document.title,
|
||||
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(document.team?.url)}/${
|
||||
document.id
|
||||
}`,
|
||||
rejectionReason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const branding = document.team?.teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(ownerTemplate, { lang: documentMeta?.language, branding }),
|
||||
renderEmailWithI18N(ownerTemplate, {
|
||||
lang: documentMeta?.language,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: documentOwner.name || '',
|
||||
address: documentOwner.email,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
|
||||
await io.runTask('update-recipient', async () => {
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID,
|
||||
z.infer<typeof SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA>
|
||||
>;
|
||||
@ -17,14 +17,12 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.string(),
|
||||
includeSenderDetails: z.boolean(),
|
||||
includeSigningCertificate: z.boolean(),
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z.string(),
|
||||
brandingUrl: z.string(),
|
||||
brandingCompanyDetails: z.string(),
|
||||
brandingHidePoweredBy: z.boolean(),
|
||||
teamId: z.number(),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
})
|
||||
.nullish(),
|
||||
}),
|
||||
|
||||
@ -57,17 +57,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
||||
},
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
team: {
|
||||
select: {
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -127,13 +117,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
|
||||
}
|
||||
|
||||
const pdfData = await getFile(documentData);
|
||||
const certificateData =
|
||||
(document.team?.teamGlobalSettings?.includeSigningCertificate ?? true)
|
||||
? await getCertificatePdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
const certificateData = await getCertificatePdf({ documentId }).catch(() => null);
|
||||
|
||||
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
|
||||
const pdfDoc = await PDFDocument.load(pdfData);
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
"pg": "^8.11.3",
|
||||
"playwright": "1.43.0",
|
||||
"react": "^18",
|
||||
"remeda": "^2.17.3",
|
||||
"remeda": "^2.12.1",
|
||||
"sharp": "0.32.6",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
|
||||
@ -1,25 +1,21 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { generateHOTP } from 'oslo/otp';
|
||||
import { TOTPController } from 'oslo/otp';
|
||||
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
const totp = new TOTPController();
|
||||
|
||||
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||
user: User;
|
||||
totpCode: string;
|
||||
// The number of windows to look back
|
||||
window?: number;
|
||||
// The duration that the token is valid for in seconds
|
||||
period?: number;
|
||||
};
|
||||
|
||||
export const verifyTwoFactorAuthenticationToken = async ({
|
||||
user,
|
||||
totpCode,
|
||||
window = 1,
|
||||
period = 30_000,
|
||||
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
@ -31,21 +27,7 @@ export const verifyTwoFactorAuthenticationToken = async ({
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const decodedSecret = base32.decode(secret);
|
||||
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||
|
||||
let now = Date.now();
|
||||
|
||||
for (let i = 0; i < window; i++) {
|
||||
const counter = Math.floor(now / period);
|
||||
|
||||
const hotp = await generateHOTP(decodedSecret, counter);
|
||||
|
||||
if (totpCode === hotp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
now -= period;
|
||||
}
|
||||
|
||||
return false;
|
||||
return isValidToken;
|
||||
};
|
||||
|
||||
@ -13,7 +13,6 @@ export const getDocumentStats = async () => {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.BIN]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ export const getRecipientsStats = async () => {
|
||||
[ReadStatus.NOT_OPENED]: 0,
|
||||
[SigningStatus.SIGNED]: 0,
|
||||
[SigningStatus.NOT_SIGNED]: 0,
|
||||
[SigningStatus.REJECTED]: 0,
|
||||
[SendStatus.SENT]: 0,
|
||||
[SendStatus.NOT_SENT]: 0,
|
||||
};
|
||||
|
||||
@ -112,7 +112,6 @@ export const createDocument = async ({
|
||||
documentMeta: {
|
||||
create: {
|
||||
language: team?.teamGlobalSettings?.documentLanguage,
|
||||
typedSignatureEnabled: team?.teamGlobalSettings?.typedSignatureEnabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -158,16 +158,6 @@ const handleDocumentOwnerDelete = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
// Soft delete for document recipients since the owner is deleting it
|
||||
await tx.recipient.updateMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
data: {
|
||||
documentDeletedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
|
||||
@ -64,10 +64,8 @@ export const findDocumentAuditLogs = async ({
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
|
||||
],
|
||||
|
||||
@ -2,8 +2,15 @@ import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, DocumentSource, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||
import { Prisma, RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import type {
|
||||
Document,
|
||||
DocumentSource,
|
||||
Prisma,
|
||||
Team,
|
||||
TeamEmail,
|
||||
User,
|
||||
} from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
@ -81,12 +88,14 @@ export const findDocuments = async ({
|
||||
const teamMemberRole = team?.members[0].role ?? null;
|
||||
|
||||
const termFilters = match(term)
|
||||
.with(P.string.minLength(1), () => ({
|
||||
title: {
|
||||
contains: term,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
}))
|
||||
.with(P.string.minLength(1), () => {
|
||||
return {
|
||||
title: {
|
||||
contains: term,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
} as const;
|
||||
})
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
@ -132,8 +141,6 @@ export const findDocuments = async ({
|
||||
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
|
||||
|
||||
console.log('find documets team', team);
|
||||
|
||||
if (team) {
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters);
|
||||
}
|
||||
@ -286,21 +293,19 @@ export const findDocuments = async ({
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
|
||||
export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -309,7 +314,6 @@ export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User)
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -326,7 +330,6 @@ export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User)
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
@ -341,7 +344,6 @@ export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User)
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -352,7 +354,6 @@ export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User)
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -364,49 +365,12 @@ export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User)
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
documentDeletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.BIN, () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
deletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
Recipient: {
|
||||
some: {
|
||||
email: user.email,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
documentDeletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -444,7 +408,7 @@ export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User)
|
||||
* @param team The team to find the documents for.
|
||||
* @returns A filter which can be applied to the Prisma Document schema.
|
||||
*/
|
||||
export const findTeamDocumentsFilter = (
|
||||
const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
visibilityFilters: Prisma.DocumentWhereInput[],
|
||||
@ -454,16 +418,17 @@ export const findTeamDocumentsFilter = (
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
// Filter to display all documents that belong to the team.
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
deletedAt: null,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail && filter.OR) {
|
||||
// Filter to display all documents received by the team email that are not draft.
|
||||
filter.OR.push({
|
||||
status: {
|
||||
not: ExtendedDocumentStatus.DRAFT,
|
||||
@ -473,15 +438,14 @@ export const findTeamDocumentsFilter = (
|
||||
email: teamEmail,
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
OR: visibilityFilters,
|
||||
});
|
||||
|
||||
// Filter to display all documents that have been sent by the team email.
|
||||
filter.OR.push({
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
deletedAt: null,
|
||||
OR: visibilityFilters,
|
||||
});
|
||||
}
|
||||
@ -489,6 +453,7 @@ export const findTeamDocumentsFilter = (
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.INBOX, () => {
|
||||
// Return a filter that will return nothing.
|
||||
if (!teamEmail) {
|
||||
return null;
|
||||
}
|
||||
@ -506,7 +471,6 @@ export const findTeamDocumentsFilter = (
|
||||
},
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
OR: visibilityFilters,
|
||||
};
|
||||
})
|
||||
@ -516,7 +480,6 @@ export const findTeamDocumentsFilter = (
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.DRAFT,
|
||||
deletedAt: null,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
@ -528,7 +491,6 @@ export const findTeamDocumentsFilter = (
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
deletedAt: null,
|
||||
OR: visibilityFilters,
|
||||
});
|
||||
}
|
||||
@ -541,7 +503,6 @@ export const findTeamDocumentsFilter = (
|
||||
{
|
||||
teamId: team.id,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
@ -570,7 +531,6 @@ export const findTeamDocumentsFilter = (
|
||||
OR: visibilityFilters,
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -579,7 +539,6 @@ export const findTeamDocumentsFilter = (
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
@ -609,42 +568,5 @@ export const findTeamDocumentsFilter = (
|
||||
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.BIN, () => {
|
||||
const filters: Prisma.DocumentWhereInput[] = [
|
||||
{
|
||||
teamId: team.id,
|
||||
deletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (teamEmail) {
|
||||
filters.push(
|
||||
{
|
||||
User: {
|
||||
email: teamEmail,
|
||||
},
|
||||
deletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
Recipient: {
|
||||
some: {
|
||||
email: teamEmail,
|
||||
documentDeletedAt: {
|
||||
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
OR: filters,
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
type PeriodSelectorValue,
|
||||
findDocumentsFilter,
|
||||
findTeamDocumentsFilter,
|
||||
} from '@documenso/lib/server-only/document/find-documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export type GetStatsInput = {
|
||||
user: User;
|
||||
team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
|
||||
period?: PeriodSelectorValue;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.INBOX]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
[ExtendedDocumentStatus.BIN]: 0,
|
||||
};
|
||||
|
||||
const searchFilter: Prisma.DocumentWhereInput = search
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const visibilityFilters = [
|
||||
match(options.team?.currentTeamMember?.role)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
|
||||
];
|
||||
|
||||
const statusCounts = await Promise.all(
|
||||
Object.values(ExtendedDocumentStatus).map(async (status) => {
|
||||
if (status === ExtendedDocumentStatus.ALL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = options.team
|
||||
? findTeamDocumentsFilter(status, options.team, visibilityFilters)
|
||||
: findDocumentsFilter(status, user);
|
||||
|
||||
if (filter === null) {
|
||||
return { status, count: 0 };
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
...filter,
|
||||
...(createdAt && { createdAt }),
|
||||
...searchFilter,
|
||||
};
|
||||
|
||||
const count = await prisma.document.count({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
return { status, count };
|
||||
}),
|
||||
);
|
||||
|
||||
statusCounts.forEach((result) => {
|
||||
if (result) {
|
||||
stats[result.status] = result.count;
|
||||
if (
|
||||
result.status !== ExtendedDocumentStatus.BIN &&
|
||||
[
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
].includes(result.status)
|
||||
) {
|
||||
stats[ExtendedDocumentStatus.ALL] += result.count;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user