mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
39 Commits
v1.7.2-rc.
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eff336175 | |||
| 9bdd5c31cc | |||
| 57ad7c150b | |||
| b0829e6cdf | |||
| 08a446fefd | |||
| f15f9ecdd1 | |||
| 979e3f3e71 | |||
| 876803b5db | |||
| 1c87cb1e0d | |||
| 5398026b80 | |||
| f2439abbc9 | |||
| 5a6e031c90 | |||
| bcc3b70335 | |||
| 5a26610a01 | |||
| 5d7a979baf | |||
| 552825b79e | |||
| 786566bae4 | |||
| cb23357b42 | |||
| 0078162159 | |||
| 19e23d8ef3 | |||
| e3b7ec82a3 | |||
| 23a0537648 | |||
| f6bcf921d5 | |||
| 451723a8ab | |||
| 9b769e7e33 | |||
| 61ea4971ad | |||
| ffc61af904 | |||
| efbe94aea8 | |||
| 1b10c55758 | |||
| 3da4603a47 | |||
| dcc2ac8a71 | |||
| 5158584955 | |||
| 54c0c6be14 | |||
| 927a24249c | |||
| a50c758b07 | |||
| cc249357b3 | |||
| 011dabcc04 | |||
| 4fa6dc1e21 | |||
| 32b65c4d49 |
@ -93,6 +93,8 @@ NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
||||
# REQUIRED: Defines the email address to use as the from address.
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
||||
# OPTIONAL: Defines the service for nodemailer
|
||||
NEXT_PRIVATE_SMTP_SERVICE=
|
||||
# OPTIONAL: The API key to use for Resend.com
|
||||
NEXT_PRIVATE_RESEND_API_KEY=
|
||||
# OPTIONAL: The API key to use for MailChannels.
|
||||
|
||||
54
.github/workflows/publish.yml
vendored
54
.github/workflows/publish.yml
vendored
@ -89,22 +89,35 @@ 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)"
|
||||
|
||||
docker manifest create \
|
||||
documenso/documenso:latest \
|
||||
--amend documenso/documenso-amd64:latest \
|
||||
--amend documenso/documenso-arm64:latest \
|
||||
# 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:$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
|
||||
|
||||
@ -113,21 +126,34 @@ 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)"
|
||||
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:latest \
|
||||
--amend ghcr.io/documenso/documenso-amd64:latest \
|
||||
--amend ghcr.io/documenso/documenso-arm64:latest \
|
||||
# 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:$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
|
||||
|
||||
@ -11,6 +11,10 @@ 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
|
||||
@ -38,11 +42,17 @@ You will be prompted to enter some information, such as the certificate's Common
|
||||
Combine the private key and the self-signed certificate to create a `.p12` certificate. Use the following command:
|
||||
|
||||
```bash
|
||||
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt
|
||||
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -legacy
|
||||
```
|
||||
|
||||
<Callout type="warning">
|
||||
If you get the error "Error: Failed to get private key bags", add the `-legacy` flag to the command `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -legacy`.
|
||||
When running the application in Docker, you may encounter permission issues when attempting to sign documents using your certificate (.p12) file. This happens because the application runs as a non-root user inside the container and needs read access to the certificate.
|
||||
|
||||
To resolve this, you'll need to update the certificate file permissions to allow the container user 1001, which runs NextJS, to read it:
|
||||
|
||||
```bash
|
||||
sudo chown 1001 certificate.p12
|
||||
```
|
||||
|
||||
</Callout>
|
||||
|
||||
@ -54,8 +64,8 @@ Note that for local development, the password can be left empty.
|
||||
|
||||
### Add Certificate to the Project
|
||||
|
||||
Finally, add the certificate to the project. Place the `certificate.p12` file in the `/apps/web/resources` directory. If the directory doesn't exist, create it.
|
||||
Use the `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` environment variable to point at the certificate you created.
|
||||
|
||||
The final file path should be `/apps/web/resources/certificate.p12`.
|
||||
Details about environment variables associated with certificates can be found [here](/developers/self-hosting/signing-certificate#configure-documenso-to-use-the-certificate).
|
||||
|
||||
</Steps>
|
||||
|
||||
@ -133,7 +133,7 @@ volumes:
|
||||
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -d up
|
||||
docker-compose --env-file ./.env up -d
|
||||
```
|
||||
|
||||
The command will start the PostgreSQL database and the Documenso application containers.
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"templates": "Templates",
|
||||
"direct-links": "Direct Signing Links",
|
||||
"document-visibility": "Document Visibility",
|
||||
"teams": "Teams",
|
||||
"-- Legal Overview": {
|
||||
"type": "separator",
|
||||
"title": "Legal Overview"
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
---
|
||||
title: Document Visibility
|
||||
description: Learn how to control the visibility of your team documents.
|
||||
---
|
||||
|
||||
# Team's Document Visibility
|
||||
|
||||
By default, all documents created in a team are visible to all team members. However, you can control the visibility of your documents by changing the document's visibility settings.
|
||||
|
||||
To set the visibility of a document, click on the **Document visibility** dropdown in the document's settings panel.
|
||||
|
||||

|
||||
|
||||
The document visibility can be set to one of the following options:
|
||||
|
||||
- **Everyone** - The document is visible to all team members.
|
||||
- **Managers and above** - The document is visible to people with the role of Manager or above.
|
||||
- **Admin only** - The document is only visible to the team's admins.
|
||||
5
apps/documentation/pages/users/teams/_meta.json
Normal file
5
apps/documentation/pages/users/teams/_meta.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"general-settings": "General Settings",
|
||||
"document-visibility": "Document Visibility",
|
||||
"sender-details": "Email Sender Details"
|
||||
}
|
||||
45
apps/documentation/pages/users/teams/document-visibility.mdx
Normal file
45
apps/documentation/pages/users/teams/document-visibility.mdx
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Document Visibility
|
||||
description: Learn how to control the visibility of your team documents.
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
# Team's Document Visibility
|
||||
|
||||
The default document visibility option allows you to control who can view and access the documents uploaded to your team account. The document visibility can be set to one of the following options:
|
||||
|
||||
- **Everyone** - The document is visible to all team members.
|
||||
- **Managers and above** - The document is visible to team members with the role of _Manager or above_ and _Admin_.
|
||||
- **Admin only** - The document is only visible to the team's admins.
|
||||
|
||||

|
||||
|
||||
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general settings page](/users/teams/general-settings) and selecting a different visibility option.
|
||||
|
||||
<Callout type="warning">
|
||||
If the team member uploading the document has a role lower than the default document visibility,
|
||||
the document visibility will be set to a lower visibility level matching the team member's role.
|
||||
</Callout>
|
||||
|
||||
Here's how it works:
|
||||
|
||||
- If a user with the "_Member_" role creates a document and the default document visibility is set to "_Admin_" or "_Managers and above_", the document's visibility is set to "_Everyone_".
|
||||
- If a user with the "_Manager_" role creates a document and the default document visibility is set to "_Admin_", the document's visibility is set to "_Managers and above_".
|
||||
- Otherwise, the document's visibility is set to the default document visibility.
|
||||
|
||||
You can change the visibility of a document at any time by editing the document and selecting a different visibility option.
|
||||
|
||||

|
||||
|
||||
<Callout type="warning">
|
||||
Updating the default document visibility in the team's general settings will not affect the
|
||||
visibility of existing documents. You will need to update the visibility of each document
|
||||
individually.
|
||||
</Callout>
|
||||
|
||||
## A Note on Document Access
|
||||
|
||||
The `document owner` (the user who created the document) always has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the document owner can still view and edit the document.
|
||||
|
||||
The `recipient` (the user who receives the document for signature, approval, etc.) also has access to the document, regardless of the document's visibility settings. This means that even if a document is set to "Admins only", the recipient can still view and sign the document.
|
||||
15
apps/documentation/pages/users/teams/general-settings.mdx
Normal file
15
apps/documentation/pages/users/teams/general-settings.mdx
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: General Settings
|
||||
description: Learn how to manage your team's General settings.
|
||||
---
|
||||
|
||||
# General Settings
|
||||
|
||||
You can manage your team's general settings by clicking on the **General Settings** tab in the team's settings dashboard.
|
||||
|
||||

|
||||
|
||||
The general settings page allows you to update the following settings:
|
||||
|
||||
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
|
||||
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).
|
||||
14
apps/documentation/pages/users/teams/sender-details.mdx
Normal file
14
apps/documentation/pages/users/teams/sender-details.mdx
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Email Sender Details
|
||||
description: Learn how to update the sender details for your team's email notifications.
|
||||
---
|
||||
|
||||
## Sender Details
|
||||
|
||||
If the **Sender Details** setting is enabled, the emails sent by the team will include the sender's name. The email will say:
|
||||
|
||||
> "Example User" on behalf of "Example Team" has invited you to sign "document.pdf"
|
||||
|
||||
If the **Sender Details** setting is disabled, the emails sent by the team will not include the sender's name. The email will say:
|
||||
|
||||
> "Example Team" has invited you to sign "document.pdf"
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
BIN
apps/documentation/public/teams/team-general-settings.webp
Normal file
BIN
apps/documentation/public/teams/team-general-settings.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
64
apps/marketing/content/blog/project-babel.mdx
Normal file
64
apps/marketing/content/blog/project-babel.mdx
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
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
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.7.2-rc.4",
|
||||
"version": "1.8.0-rc.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
BIN
apps/marketing/public/blog/babel.png
Normal file
BIN
apps/marketing/public/blog/babel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 467 KiB |
23
apps/marketing/src/app/(marketing)/[content]/content.tsx
Normal file
23
apps/marketing/src/app/(marketing)/[content]/content.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import type { DocumentTypes } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
export type ContentPageContentProps = {
|
||||
document: DocumentTypes;
|
||||
};
|
||||
|
||||
export const ContentPageContent = ({ document }: ContentPageContentProps) => {
|
||||
const MDXContent = useMDXComponent(document.body.code);
|
||||
|
||||
return <MDXContent components={mdxComponents} />;
|
||||
};
|
||||
@ -1,12 +1,11 @@
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { allDocuments } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { ContentPageContent } from './content';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||
@ -19,12 +18,6 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
|
||||
return { title: document.title };
|
||||
};
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* A generic catch all page for the root level that checks for content layer documents.
|
||||
*
|
||||
@ -39,11 +32,9 @@ export default async function ContentPage({ params }: { params: { content: strin
|
||||
notFound();
|
||||
}
|
||||
|
||||
const MDXContent = useMDXComponent(post.body.code);
|
||||
|
||||
return (
|
||||
<article className="prose dark:prose-invert mx-auto">
|
||||
<MDXContent components={mdxComponents} />
|
||||
<ContentPageContent document={post} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
23
apps/marketing/src/app/(marketing)/blog/[post]/content.tsx
Normal file
23
apps/marketing/src/app/(marketing)/blog/[post]/content.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import type { BlogPost } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
export type BlogPostContentProps = {
|
||||
post: BlogPost;
|
||||
};
|
||||
|
||||
export const BlogPostContent = ({ post }: BlogPostContentProps) => {
|
||||
const MdxContent = useMDXComponent(post.body.code);
|
||||
|
||||
return <MdxContent components={mdxComponents} />;
|
||||
};
|
||||
@ -1,16 +1,15 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { allBlogPosts } from 'contentlayer/generated';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
||||
|
||||
import { BlogPostContent } from './content';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
@ -42,12 +41,6 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
export default async function BlogPostPage({ params }: { params: { post: string } }) {
|
||||
await setupI18nSSR();
|
||||
|
||||
@ -57,8 +50,6 @@ export default async function BlogPostPage({ params }: { params: { post: string
|
||||
notFound();
|
||||
}
|
||||
|
||||
const MDXContent = useMDXComponent(post.body.code);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<article className="prose dark:prose-invert mx-auto py-8">
|
||||
@ -87,7 +78,7 @@ export default async function BlogPostPage({ params }: { params: { post: string
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MDXContent components={mdxComponents} />
|
||||
<BlogPostContent post={post} />
|
||||
|
||||
{post.tags.length > 0 && (
|
||||
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
@ -130,9 +131,9 @@ const fetchEarlyAdopters = async () => {
|
||||
};
|
||||
|
||||
export default async function OpenPage() {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = i18n;
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [
|
||||
{ forks_count: forksCount, stargazers_count: stargazersCount },
|
||||
|
||||
@ -163,6 +163,7 @@ 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.7.2-rc.4",
|
||||
"version": "1.8.0-rc.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@ -33,6 +33,8 @@ import {
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||
|
||||
import { ResendDocumentActionItem } from '../_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from '../delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
|
||||
@ -62,6 +64,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
|
||||
const isOwner = document.User.id === session.user.id;
|
||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||
const isPending = document.status === DocumentStatus.PENDING;
|
||||
const isDeleted = document.deletedAt !== null;
|
||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
@ -145,6 +148,21 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
<Trans>Share</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{canManageDocument && (
|
||||
<DocumentRecipientLinkCopyDialog
|
||||
recipients={document.Recipient}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={!isPending || isDeleted}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Signing Links</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResendDocumentActionItem
|
||||
document={document}
|
||||
recipients={nonSignedRecipients}
|
||||
|
||||
@ -4,7 +4,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -133,6 +133,11 @@ 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" />
|
||||
@ -143,17 +148,11 @@ export const DocumentPageViewRecentActivity = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Todo: Translations. */}
|
||||
<p
|
||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
|
||||
formatDocumentAuditLogAction(auditLog, userId).description
|
||||
}`}
|
||||
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
>
|
||||
<span className="text-foreground font-medium">
|
||||
{formatDocumentAuditLogAction(auditLog, userId).prefix}
|
||||
</span>{' '}
|
||||
{formatDocumentAuditLogAction(auditLog, userId).description}
|
||||
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
</p>
|
||||
|
||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||
|
||||
@ -1,16 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
MailIcon,
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
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 = {
|
||||
document: Document & {
|
||||
@ -24,6 +38,7 @@ export const DocumentPageViewRecipients = ({
|
||||
documentRootPath,
|
||||
}: DocumentPageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const recipients = document.Recipient;
|
||||
|
||||
@ -68,53 +83,89 @@ export const DocumentPageViewRecipients = ({
|
||||
}
|
||||
/>
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||
<Badge variant="default">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Approved</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.CC, () =>
|
||||
document.status === DocumentStatus.COMPLETED ? (
|
||||
<>
|
||||
<MailIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Sent</Trans>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-row items-center">
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||
<Badge variant="default">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Ready</Trans>
|
||||
<Trans>Approved</Trans>
|
||||
</>
|
||||
),
|
||||
)
|
||||
))
|
||||
.with(RecipientRole.CC, () =>
|
||||
document.status === DocumentStatus.COMPLETED ? (
|
||||
<>
|
||||
<MailIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Sent</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Ready</Trans>
|
||||
</>
|
||||
),
|
||||
)
|
||||
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<>
|
||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Viewed</Trans>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<>
|
||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Viewed</Trans>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</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 && (
|
||||
<CopyTextButton
|
||||
value={formatSigningLink(recipient.token)}
|
||||
onCopySuccess={() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -26,6 +26,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
@ -73,7 +74,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (team && !isRecipient) {
|
||||
if (team && !isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
@ -134,6 +135,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||
)}
|
||||
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -177,8 +178,8 @@ export const EditDocumentForm = ({
|
||||
stepIndex: 3,
|
||||
},
|
||||
subject: {
|
||||
title: msg`Add Subject`,
|
||||
description: msg`Add the subject and message you wish to send to signers.`,
|
||||
title: msg`Distribute Document`,
|
||||
description: msg`Choose how the document will reach recipients`,
|
||||
stepIndex: 4,
|
||||
},
|
||||
};
|
||||
@ -307,7 +308,7 @@ export const EditDocumentForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.meta;
|
||||
const { subject, message, distributionMethod, emailSettings } = data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
@ -316,16 +317,31 @@ export const EditDocumentForm = ({
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailSettings,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Document sent`),
|
||||
description: _(msg`Your document has been sent successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
|
||||
toast({
|
||||
title: _(msg`Document sent`),
|
||||
description: _(msg`Your document has been sent successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(documentRootPath);
|
||||
router.push(documentRootPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
toast({
|
||||
title: _(msg`Links Generated`),
|
||||
description: _(msg`Signing links have been generated for this document.`),
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
router.push(`${documentRootPath}/${document.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
|
||||
let canAccessDocument = true;
|
||||
|
||||
if (!isRecipient) {
|
||||
if (!isRecipient && document?.userId !== user.id) {
|
||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
|
||||
@ -58,10 +58,6 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
||||
});
|
||||
};
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
@ -103,9 +99,7 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
||||
{
|
||||
header: _(msg`Action`),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<span>{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
|
||||
@ -139,6 +139,7 @@ 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,12 +14,14 @@ export type DownloadCertificateButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
documentStatus: DocumentStatus;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const DownloadCertificateButton = ({
|
||||
className,
|
||||
documentId,
|
||||
documentStatus,
|
||||
teamId,
|
||||
}: DownloadCertificateButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
@ -29,7 +31,7 @@ export const DownloadCertificateButton = ({
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadCertificate({ documentId });
|
||||
const { url } = await downloadCertificate({ documentId, teamId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
|
||||
@ -37,6 +37,8 @@ import {
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||
|
||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||
@ -69,7 +71,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
// const isRecipient = !!recipient;
|
||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
// const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
@ -191,6 +193,20 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
<Trans>Share</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{canManageDocument && (
|
||||
<DocumentRecipientLinkCopyDialog
|
||||
recipients={row.Recipient}
|
||||
trigger={
|
||||
<DropdownMenuItem disabled={!isPending} asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Signing Links</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||
|
||||
<DocumentShareButton
|
||||
|
||||
@ -78,7 +78,7 @@ export const DocumentsDataTable = ({
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
|
||||
@ -44,11 +44,11 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
|
||||
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
||||
|
||||
const onSubscribeClick = async (priceId: string) => {
|
||||
try {
|
||||
setIsFetchingCheckoutSession(true);
|
||||
setCheckoutSessionPriceId(priceId);
|
||||
|
||||
const url = await createCheckout({ priceId });
|
||||
|
||||
@ -64,7 +64,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingCheckoutSession(false);
|
||||
setCheckoutSessionPriceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -122,7 +122,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
loading={isFetchingCheckoutSession}
|
||||
disabled={checkoutSessionPriceId !== null}
|
||||
loading={checkoutSessionPriceId === price.id}
|
||||
onClick={() => void onSubscribeClick(price.id)}
|
||||
>
|
||||
<Trans>Subscribe</Trans>
|
||||
|
||||
@ -12,9 +12,10 @@ import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -63,7 +64,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
<Trans>Manage Subscription</Trans>
|
||||
{children || <Trans>Manage Subscription</Trans>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@ -68,60 +68,74 @@ export default async function BillingSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
<Trans>Billing</Trans>
|
||||
</h3>
|
||||
|
||||
<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>
|
||||
{subscriptionProduct ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</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>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
))
|
||||
.with('PAST_DUE', () => (
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{isMissingOrInactiveOrFreePlan && (
|
||||
<p>
|
||||
<Trans>
|
||||
Your current plan is past due. Please update your payment information.
|
||||
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
)}
|
||||
|
||||
{/* Todo: Translation */}
|
||||
{!isMissingOrInactiveOrFreePlan &&
|
||||
match(subscription.status)
|
||||
.with('ACTIVE', () => (
|
||||
<p>
|
||||
{subscriptionProduct ? (
|
||||
<span>
|
||||
You are currently subscribed to{' '}
|
||||
<span className="font-semibold">{subscriptionProduct.name}</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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
14
apps/web/src/app/(dashboard)/templates/[id]/edit/page.tsx
Normal file
14
apps/web/src/app/(dashboard)/templates/[id]/edit/page.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import type { TemplateEditPageViewProps } from './template-edit-page-view';
|
||||
import { TemplateEditPageView } from './template-edit-page-view';
|
||||
|
||||
type TemplateEditPageProps = Pick<TemplateEditPageViewProps, 'params'>;
|
||||
|
||||
export default async function TemplateEditPage({ params }: TemplateEditPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return <TemplateEditPageView params={params} />;
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '../../template-direct-link-badge';
|
||||
import { TemplateDirectLinkDialogWrapper } from '../template-direct-link-dialog-wrapper';
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplateEditPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplateEditPageView = async ({ params, team }: TemplateEditPageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateWithDetailsById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<Link
|
||||
href={`${templateRootPath}/${templateId}`}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Template</Trans>
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
|
||||
|
||||
{template.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={template.directLink.token}
|
||||
enabled={template.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-6"
|
||||
initialTemplate={template}
|
||||
templateRootPath={templateRootPath}
|
||||
isEnterprise={isTemplateEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import type { TemplatePageViewProps } from './template-page-view';
|
||||
import { TemplatePageView } from './template-page-view';
|
||||
|
||||
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
||||
export type TemplatePageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
@ -10,11 +10,13 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
export type TemplateDirectLinkDialogWrapperProps = {
|
||||
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
|
||||
export const TemplateDirectLinkDialogWrapper = ({
|
||||
template,
|
||||
}: TemplateDirectLinkDialogWrapperProps) => {
|
||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { SelectItem } from '@documenso/ui/primitives/select';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { SearchParamSelector } from '~/components/forms/search-param-selector';
|
||||
|
||||
import { DataTableActionButton } from '../../documents/data-table-action-button';
|
||||
import { DataTableActionDropdown } from '../../documents/data-table-action-dropdown';
|
||||
import { DataTableTitle } from '../../documents/data-table-title';
|
||||
|
||||
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||
DOCUMENT: msg`Document`,
|
||||
TEMPLATE: msg`Template`,
|
||||
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
||||
};
|
||||
|
||||
const ZTemplateSearchParamsSchema = ZBaseTableSearchParamsSchema.extend({
|
||||
source: z
|
||||
.nativeEnum(DocumentSource)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
status: z
|
||||
.nativeEnum(DocumentStatusEnum)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
search: z.coerce
|
||||
.string()
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
});
|
||||
|
||||
type TemplatePageViewDocumentsTableProps = {
|
||||
templateId: number;
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplatePageViewDocumentsTable = ({
|
||||
templateId,
|
||||
team,
|
||||
}: TemplatePageViewDocumentsTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZTemplateSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||
trpc.document.findDocuments.useQuery(
|
||||
{
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
search: parsedSearchParams.search,
|
||||
source: parsedSearchParams.source,
|
||||
status: parsedSearchParams.status,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: _(msg`Created`),
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) =>
|
||||
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
||||
},
|
||||
{
|
||||
header: _(msg`Title`),
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||
},
|
||||
|
||||
{
|
||||
header: _(msg`Recipient`),
|
||||
accessorKey: 'recipient',
|
||||
cell: ({ row }) => (
|
||||
<StackAvatarsWithTooltip
|
||||
recipients={row.original.Recipient}
|
||||
documentStatus={row.original.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: _(msg`Status`),
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
header: () => (
|
||||
<div className="flex flex-row items-center">
|
||||
<Trans>Source</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
|
||||
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<Trans>Template</Trans>
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
This document was created by you or a team member using the template above.
|
||||
</Trans>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<Trans>Direct Link</Trans>
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<Trans>This document was created using a direct link.</Trans>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: _(msg`Actions`),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<DataTableActionButton team={team} row={row.original} />
|
||||
|
||||
<DataTableActionDropdown team={team} row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-row space-x-4">
|
||||
<DocumentSearch />
|
||||
|
||||
<SearchParamSelector
|
||||
paramKey="status"
|
||||
isValueValid={(value) =>
|
||||
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
|
||||
}
|
||||
>
|
||||
<SelectItem value="all">
|
||||
<Trans>Any Status</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.COMPLETED}>
|
||||
<Trans>Completed</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.PENDING}>
|
||||
<Trans>Pending</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.DRAFT}>
|
||||
<Trans>Draft</Trans>
|
||||
</SelectItem>
|
||||
</SearchParamSelector>
|
||||
|
||||
<SearchParamSelector
|
||||
paramKey="source"
|
||||
isValueValid={(value) =>
|
||||
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
|
||||
}
|
||||
>
|
||||
<SelectItem value="all">
|
||||
<Trans>Any Source</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentSource.TEMPLATE}>
|
||||
<Trans>Template</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
|
||||
<Trans>Direct Link</Trans>
|
||||
</SelectItem>
|
||||
</SearchParamSelector>
|
||||
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading && isInitialLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell className="py-4 pr-4">
|
||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import type { Template, User } from '@documenso/prisma/client';
|
||||
|
||||
export type TemplatePageViewInformationProps = {
|
||||
userId: number;
|
||||
template: Template & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
};
|
||||
};
|
||||
|
||||
export const TemplatePageViewInformation = ({
|
||||
template,
|
||||
userId,
|
||||
}: TemplatePageViewInformationProps) => {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const templateInformation = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
description: msg`Uploaded by`,
|
||||
value: userId === template.userId ? _(msg`You`) : template.User.name ?? template.User.email,
|
||||
},
|
||||
{
|
||||
description: msg`Created`,
|
||||
value: i18n.date(template.createdAt, { dateStyle: 'medium' }),
|
||||
},
|
||||
{
|
||||
description: msg`Last modified`,
|
||||
value: DateTime.fromJSDate(template.updatedAt)
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toRelative(),
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMounted, template, userId]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||
<h1 className="px-4 py-3 font-medium">
|
||||
<Trans>Information</Trans>
|
||||
</h1>
|
||||
|
||||
<ul className="divide-y border-t">
|
||||
{templateInformation.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
||||
>
|
||||
<span className="text-muted-foreground">{_(item.description)}</span>
|
||||
<span>{item.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentSource } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type TemplatePageViewRecentActivityProps = {
|
||||
templateId: number;
|
||||
teamId?: number;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
export const TemplatePageViewRecentActivity = ({
|
||||
templateId,
|
||||
teamId,
|
||||
documentRootPath,
|
||||
}: TemplatePageViewRecentActivityProps) => {
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
|
||||
templateId,
|
||||
teamId,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'asc',
|
||||
},
|
||||
perPage: 5,
|
||||
});
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<Trans>Recent documents</Trans>
|
||||
</h1>
|
||||
|
||||
{/* Can add dropdown menu here for additional options. */}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center py-16">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||
<p className="text-foreground/80 text-sm">
|
||||
<Trans>Unable to load documents</Trans>
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => refetch()}
|
||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
<Trans>Click here to retry</Trans>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<ul role="list" className="space-y-6 p-4">
|
||||
{data.data.length > 0 && results.totalPages > 1 && (
|
||||
<li className="relative flex gap-x-4">
|
||||
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
|
||||
<div className="bg-border w-px" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.scrollTo({
|
||||
top: document.getElementById('documents')?.offsetTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
className="text-foreground/70 hover:text-muted-foreground flex items-center text-xs"
|
||||
>
|
||||
<Trans>View more</Trans>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{results.data.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<p className="text-muted-foreground/70 text-sm">
|
||||
<Trans>No recent documents</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.data.map((document, documentIndex) => (
|
||||
<li key={document.id} className="relative flex gap-x-4">
|
||||
<div
|
||||
className={cn(
|
||||
documentIndex === results.data.length - 1 ? 'h-6' : '-bottom-6',
|
||||
'absolute left-0 top-0 flex w-6 justify-center',
|
||||
)}
|
||||
>
|
||||
<div className="bg-border w-px" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`${documentRootPath}/${document.id}`}
|
||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||
>
|
||||
{match(document.source)
|
||||
.with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => (
|
||||
<Trans>
|
||||
Document created by <span className="font-bold">{document.User.name}</span>
|
||||
</Trans>
|
||||
))
|
||||
.with(DocumentSource.TEMPLATE_DIRECT_LINK, () => (
|
||||
<Trans>
|
||||
Document created using a <span className="font-bold">direct link</span>
|
||||
</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Link>
|
||||
|
||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||
{DateTime.fromJSDate(document.createdAt).toRelative({ style: 'short' })}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
className="mx-4 mb-4"
|
||||
onClick={() => {
|
||||
window.scrollTo({
|
||||
top: document.getElementById('documents')?.offsetTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans>View all related documents</Trans>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,69 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
template: Template & {
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
export const TemplatePageViewRecipients = ({
|
||||
template,
|
||||
templateRootPath,
|
||||
}: TemplatePageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const recipients = template.Recipient;
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<Trans>Recipients</Trans>
|
||||
</h1>
|
||||
|
||||
<Link
|
||||
href={`${templateRootPath}/${template.id}/edit?step=signers`}
|
||||
title={_(msg`Modify recipients`)}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
{recipients.length === 0 ? (
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<PenIcon className="ml-2 h-3 w-3" />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -1,22 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { ChevronLeft, LucideEdit } from 'lucide-react';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSigningOrder, SigningStatus, type Team } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { DataTableActionDropdown } from '../data-table-action-dropdown';
|
||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
import { UseTemplateDialog } from '../use-template-dialog';
|
||||
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
|
||||
import { TemplatePageViewDocumentsTable } from './template-page-view-documents-table';
|
||||
import { TemplatePageViewInformation } from './template-page-view-information';
|
||||
import { TemplatePageViewRecentActivity } from './template-page-view-recent-activity';
|
||||
import { TemplatePageViewRecipients } from './template-page-view-recipients';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
params: {
|
||||
@ -30,6 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(templateRootPath);
|
||||
@ -37,29 +44,51 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateWithDetailsById({
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
const { templateDocumentData, Field, Recipient: recipients, templateMeta } = template;
|
||||
|
||||
// Remap to fit the DocumentReadOnlyFields component.
|
||||
const readOnlyFields = Field.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
|
||||
name: '',
|
||||
email: '',
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
};
|
||||
|
||||
return {
|
||||
...field,
|
||||
Recipient: recipient,
|
||||
Signature: null,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Templates</Trans>
|
||||
</Link>
|
||||
const mockedDocumentMeta = templateMeta
|
||||
? {
|
||||
typedSignatureEnabled: false,
|
||||
...templateMeta,
|
||||
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||
documentId: 0,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Templates</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
@ -77,17 +106,97 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={`${templateRootPath}/${template.id}/edit`}>
|
||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Trans>Edit Template</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-6"
|
||||
initialTemplate={template}
|
||||
templateRootPath={templateRootPath}
|
||||
isEnterprise={isTemplateEnterprise}
|
||||
/>
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
document={template}
|
||||
key={template.id}
|
||||
documentData={templateDocumentData}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DocumentReadOnlyFields
|
||||
fields={readOnlyFields}
|
||||
showFieldStatus={false}
|
||||
documentMeta={mockedDocumentMeta}
|
||||
/>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div className="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
<div className="flex flex-row items-center justify-between px-4">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Template</Trans>
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<DataTableActionDropdown
|
||||
row={template}
|
||||
teamId={team?.id}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
||||
<Trans>Manage and view template</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
<UseTemplateDialog
|
||||
templateId={template.id}
|
||||
templateSigningOrder={template.templateMeta?.signingOrder}
|
||||
recipients={template.Recipient}
|
||||
documentRootPath={documentRootPath}
|
||||
trigger={
|
||||
<Button className="w-full">
|
||||
<Trans>Use</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Template information section. */}
|
||||
<TemplatePageViewInformation template={template} userId={user.id} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<TemplatePageViewRecipients template={template} templateRootPath={templateRootPath} />
|
||||
|
||||
{/* Recent activity section. */}
|
||||
<TemplatePageViewRecentActivity
|
||||
documentRootPath={documentRootPath}
|
||||
templateId={template.id}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16" id="documents">
|
||||
<h1 className="mb-4 text-2xl font-bold">
|
||||
<Trans>Documents created from template</Trans>
|
||||
</h1>
|
||||
|
||||
<TemplatePageViewDocumentsTable team={team} templateId={template.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ import { Trans } from '@lingui/macro';
|
||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -23,7 +23,10 @@ import { MoveTemplateDialog } from './move-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: FindTemplateRow;
|
||||
row: Template & {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
@ -57,7 +60,7 @@ export const DataTableActionDropdown = ({
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||
<Link href={`${templateRootPath}/${row.id}`}>
|
||||
<Link href={`${templateRootPath}/${row.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
|
||||
@ -124,7 +124,7 @@ export const TemplatesDataTable = ({
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type="PRIVATE" />
|
||||
<TemplateType type={row.original.type} />
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
@ -145,6 +145,7 @@ export const TemplatesDataTable = ({
|
||||
<UseTemplateDialog
|
||||
templateId={row.original.id}
|
||||
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
||||
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
|
||||
recipients={row.original.Recipient}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
|
||||
@ -73,7 +73,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
|
||||
setShowNewTemplateDialog(false);
|
||||
|
||||
router.push(`${templateRootPath}/${id}`);
|
||||
router.push(`${templateRootPath}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -15,7 +17,7 @@ import {
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentSigningOrder } from '@documenso/prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -47,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
sendDocument: z.boolean(),
|
||||
distributeDocument: z.boolean(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
@ -91,14 +93,18 @@ export type UseTemplateDialogProps = {
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
documentDistributionMethod?: DocumentDistributionMethod;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function UseTemplateDialog({
|
||||
recipients,
|
||||
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||
documentRootPath,
|
||||
templateId,
|
||||
templateSigningOrder,
|
||||
trigger,
|
||||
}: UseTemplateDialogProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@ -112,7 +118,7 @@ export function UseTemplateDialog({
|
||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||
defaultValues: {
|
||||
sendDocument: false,
|
||||
distributeDocument: false,
|
||||
recipients: recipients
|
||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||
.map((recipient) => {
|
||||
@ -143,7 +149,7 @@ export function UseTemplateDialog({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
recipients: data.recipients,
|
||||
sendDocument: data.sendDocument,
|
||||
distributeDocument: data.distributeDocument,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -152,7 +158,16 @@ export function UseTemplateDialog({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(`${documentRootPath}/${id}`);
|
||||
let documentPath = `${documentRootPath}/${id}`;
|
||||
|
||||
if (
|
||||
data.distributeDocument &&
|
||||
documentDistributionMethod === DocumentDistributionMethod.NONE
|
||||
) {
|
||||
documentPath += '?action=view-signing-links';
|
||||
}
|
||||
|
||||
router.push(documentPath);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@ -186,10 +201,12 @@ export function UseTemplateDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Use Template</Trans>
|
||||
</Button>
|
||||
{trigger || (
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Use Template</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
@ -289,43 +306,76 @@ export function UseTemplateDialog({
|
||||
<div className="mt-4 flex flex-row items-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendDocument"
|
||||
name="distributeDocument"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="sendDocument"
|
||||
id="distributeDocument"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="sendDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
{' '}
|
||||
The document will be immediately sent to recipients if this is
|
||||
checked.
|
||||
</Trans>
|
||||
</p>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>
|
||||
The document will be immediately sent to recipients if this is
|
||||
checked.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>Otherwise, the document will be created as a draft.</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<p>
|
||||
<Trans>
|
||||
Otherwise, the document will be created as a draft.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Create as pending</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<p>
|
||||
<Trans>Create the document as pending and ready to sign.</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2">
|
||||
<Trans>
|
||||
We will generate signing links for you, which you can send to
|
||||
the recipients through your method of choice.
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -341,10 +391,12 @@ export function UseTemplateDialog({
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{form.getValues('sendDocument') ? (
|
||||
{!form.getValues('distributeDocument') ? (
|
||||
<Trans>Create as draft</Trans>
|
||||
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Create and send</Trans>
|
||||
) : (
|
||||
<Trans>Create as draft</Trans>
|
||||
<Trans>Create signing links</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
@ -25,7 +25,12 @@ const dateFormat: DateTimeFormatOptions = {
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*/
|
||||
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
@ -36,11 +41,11 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Browser</TableHead>
|
||||
<TableHead>{_(msg`Time`)}</TableHead>
|
||||
<TableHead>{_(msg`User`)}</TableHead>
|
||||
<TableHead>{_(msg`Action`)}</TableHead>
|
||||
<TableHead>{_(msg`IP Address`)}</TableHead>
|
||||
<TableHead>{_(msg`Browser`)}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@ -74,7 +79,7 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
|
||||
@ -2,13 +2,18 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { DOCUMENT_STATUS } from '@documenso/lib/constants/document';
|
||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
@ -21,7 +26,17 @@ type AuditLogProps = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*
|
||||
* Cannot use dynamicActivate by itself to translate this specific page and all
|
||||
* children components because `not-found.tsx` page runs and overrides the i18n.
|
||||
*/
|
||||
export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
@ -44,6 +59,10 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||
|
||||
await dynamicActivate(i18n, documentLanguage);
|
||||
|
||||
const { data: auditLogs } = await findDocumentAuditLogs({
|
||||
documentId: documentId,
|
||||
userId: document.userId,
|
||||
@ -53,31 +72,35 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Version History</h1>
|
||||
<h1 className="my-8 text-2xl font-bold">{_(msg`Version History`)}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||
<p>
|
||||
<span className="font-medium">Document ID</span>
|
||||
<span className="font-medium">{_(msg`Document ID`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.id}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Enclosed Document</span>
|
||||
<span className="font-medium">{_(msg`Enclosed Document`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.title}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Status</span>
|
||||
<span className="font-medium">{_(msg`Status`)}</span>
|
||||
|
||||
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
|
||||
<span className="mt-1 block">
|
||||
{_(
|
||||
document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description,
|
||||
).toUpperCase()}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Owner</span>
|
||||
<span className="font-medium">{_(msg`Owner`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.User.name} ({document.User.email})
|
||||
@ -85,7 +108,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Created At</span>
|
||||
<span className="font-medium">{_(msg`Created At`)}</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
{DateTime.fromJSDate(document.createdAt)
|
||||
@ -95,7 +118,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Last Updated</span>
|
||||
<span className="font-medium">{_(msg`Last Updated`)}</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
{DateTime.fromJSDate(document.updatedAt)
|
||||
@ -105,7 +128,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Time Zone</span>
|
||||
<span className="font-medium">{_(msg`Time Zone`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.documentMeta?.timezone ?? 'N/A'}
|
||||
@ -113,13 +136,13 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-medium">Recipients</p>
|
||||
<p className="font-medium">{_(msg`Recipients`)}</p>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{document.Recipient.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
<span className="text-muted-foreground">
|
||||
[{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}]
|
||||
[{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}]
|
||||
</span>{' '}
|
||||
{recipient.name} ({recipient.email})
|
||||
</li>
|
||||
|
||||
@ -2,20 +2,24 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION_ENG,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS_ENG,
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
@ -36,11 +40,21 @@ type SigningCertificateProps = {
|
||||
};
|
||||
|
||||
const FRIENDLY_SIGNING_REASONS = {
|
||||
['__OWNER__']: `I am the owner of this document`,
|
||||
...RECIPIENT_ROLE_SIGNING_REASONS_ENG,
|
||||
['__OWNER__']: msg`I am the owner of this document`,
|
||||
...RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
};
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*
|
||||
* Cannot use dynamicActivate by itself to translate this specific page and all
|
||||
* children components because `not-found.tsx` page runs and overrides the i18n.
|
||||
*/
|
||||
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
@ -63,6 +77,10 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||
|
||||
await dynamicActivate(i18n, documentLanguage);
|
||||
|
||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||
id: documentId,
|
||||
});
|
||||
@ -98,17 +116,17 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
});
|
||||
|
||||
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
||||
.with('ACCOUNT', () => 'Account Re-Authentication')
|
||||
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
|
||||
.with('PASSKEY', () => 'Passkey Re-Authentication')
|
||||
.with('EXPLICIT_NONE', () => 'Email')
|
||||
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
||||
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
|
||||
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
||||
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
||||
.with(null, () => null)
|
||||
.exhaustive();
|
||||
|
||||
if (!authLevel) {
|
||||
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
||||
.with('ACCOUNT', () => 'Account Authentication')
|
||||
.with(null, () => 'Email')
|
||||
.with('ACCOUNT', () => _(msg`Account Authentication`))
|
||||
.with(null, () => _(msg`Email`))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
@ -147,7 +165,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
|
||||
<h1 className="my-8 text-2xl font-bold">{_(msg`Signing Certificate`)}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@ -155,9 +173,9 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Signer Events</TableHead>
|
||||
<TableHead>Signature</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
<TableHead>{_(msg`Signer Events`)}</TableHead>
|
||||
<TableHead>{_(msg`Signature`)}</TableHead>
|
||||
<TableHead>{_(msg`Details`)}</TableHead>
|
||||
{/* <TableHead>Security</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@ -173,11 +191,11 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||
<div className="break-all">{recipient.email}</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Authentication Level:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
|
||||
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
@ -199,21 +217,21 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Signature ID:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
|
||||
<span className="block font-mono uppercase">
|
||||
{signature.secondaryId}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">IP Address:</span>{' '}
|
||||
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||
<span className="font-medium">Device:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||
</span>
|
||||
@ -227,44 +245,46 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Sent:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.EMAIL_SENT[0]
|
||||
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: 'Unknown'}
|
||||
: _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Viewed:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_OPENED[0]
|
||||
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: 'Unknown'}
|
||||
: _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Signed:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
||||
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: 'Unknown'}
|
||||
: _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Reason:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{isOwner(recipient.email)
|
||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||
: FRIENDLY_SIGNING_REASONS[recipient.role]}
|
||||
{_(
|
||||
isOwner(recipient.email)
|
||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@ -280,7 +300,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||
Signing certificate provided by:
|
||||
{_(msg`Signing certificate provided by`)}:
|
||||
</p>
|
||||
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
|
||||
@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
@ -53,7 +53,9 @@ export const DirectTemplatePageView = ({
|
||||
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION_ENG[directTemplateRecipient.role];
|
||||
const recipientActionVerb = _(
|
||||
RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb,
|
||||
);
|
||||
|
||||
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
||||
configure: {
|
||||
@ -62,9 +64,8 @@ export const DirectTemplatePageView = ({
|
||||
stepIndex: 1,
|
||||
},
|
||||
sign: {
|
||||
// Todo: Translations
|
||||
title: msg`${recipientRoleDescription.actionVerb} document`,
|
||||
description: msg`${recipientRoleDescription.actionVerb} the document to complete the process.`,
|
||||
title: msg`${recipientActionVerb} document`,
|
||||
description: msg`${recipientActionVerb} the document to complete the process.`,
|
||||
stepIndex: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@ -99,6 +99,10 @@ 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
|
||||
|
||||
@ -0,0 +1,170 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
110
apps/web/src/app/(signing)/sign/[token]/rejected/page.tsx
Normal file
110
apps/web/src/app/(signing)/sign/[token]/rejected/page.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -31,6 +31,7 @@ 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';
|
||||
|
||||
@ -57,28 +58,32 @@ export const SigningPageView = ({
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const ZTeamBrandingPreferencesFormSchema = z.object({
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
|
||||
.refine(
|
||||
(file) => ACCEPTED_FILE_TYPES.includes(file.type),
|
||||
'Only .jpg, .png, and .webp files are accepted',
|
||||
)
|
||||
.nullish(),
|
||||
brandingUrl: z.string().url().optional().or(z.literal('')),
|
||||
brandingCompanyDetails: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
|
||||
|
||||
export type TeamBrandingPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
|
||||
const { mutateAsync: updateTeamBrandingSettings } =
|
||||
trpc.team.updateTeamBrandingSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamBrandingPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
brandingEnabled: settings?.brandingEnabled ?? false,
|
||||
brandingUrl: settings?.brandingUrl ?? '',
|
||||
brandingLogo: undefined,
|
||||
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
|
||||
},
|
||||
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||
|
||||
const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
|
||||
let uploadedBrandingLogo = settings?.brandingLogo;
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateTeamBrandingSettings({
|
||||
teamId: team.id,
|
||||
settings: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Branding preferences updated`),
|
||||
description: _(msg`Your branding preferences have been updated`),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We were unable to update your branding preferences at this time, please try again later`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.brandingLogo) {
|
||||
const file = JSON.parse(settings.brandingLogo);
|
||||
|
||||
if ('type' in file && 'data' in file) {
|
||||
void getFile(file).then((binaryData) => {
|
||||
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
||||
|
||||
setPreviewUrl(objectUrl);
|
||||
setHasLoadedPreview(true);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasLoadedPreview(true);
|
||||
}, [settings?.brandingLogo]);
|
||||
|
||||
// Cleanup ObjectURL on unmount or when previewUrl changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Enable Custom Branding</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Enable custom branding for all documents in this team.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-y-4">
|
||||
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9999]" />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingLogo"
|
||||
render={({ field: { value: _value, onChange, ...field } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Branding Logo</FormLabel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Logo preview"
|
||||
className="h-full w-full object-contain p-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||
Please upload a logo
|
||||
{!hasLoadedPreview && (
|
||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<FormControl className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
||||
disabled={!isBrandingEnabled}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
if (previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
setPreviewUrl(objectUrl);
|
||||
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'h-auto p-2',
|
||||
'file:text-primary hover:file:bg-primary/90',
|
||||
'file:mr-4 file:cursor-pointer file:rounded-md file:border-0',
|
||||
'file:p-2 file:py-2 file:font-medium',
|
||||
'file:bg-primary file:text-primary-foreground',
|
||||
!isBrandingEnabled && 'cursor-not-allowed',
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="absolute right-2 top-0 inline-flex h-full items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-destructive text-xs"
|
||||
onClick={() => {
|
||||
setPreviewUrl('');
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Upload your brand logo (max 5MB, JPG, PNG, or WebP)</Trans>
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Brand Website</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
disabled={!isBrandingEnabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Your brand website URL</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingCompanyDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Brand Details</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={_(msg`Enter your brand details`)}
|
||||
className="min-h-[100px] resize-y"
|
||||
disabled={!isBrandingEnabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Additional brand information to display at the bottom of emails</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
import { DocumentVisibility } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZTeamDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
includeSenderDetails: z.boolean(),
|
||||
});
|
||||
|
||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||
|
||||
export type TeamDocumentPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export const TeamDocumentPreferencesForm = ({
|
||||
team,
|
||||
settings,
|
||||
}: TeamDocumentPreferencesFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { data } = useSession();
|
||||
|
||||
const placeholderEmail = data?.user.email ?? 'user@example.com';
|
||||
|
||||
const { mutateAsync: updateTeamDocumentPreferences } =
|
||||
trpc.team.updateTeamDocumentSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamDocumentPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
|
||||
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
|
||||
? settings?.documentLanguage
|
||||
: 'en',
|
||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const includeSenderDetails = form.watch('includeSenderDetails');
|
||||
|
||||
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const { documentVisibility, documentLanguage, includeSenderDetails } = data;
|
||||
|
||||
await updateTeamDocumentPreferences({
|
||||
teamId: team.id,
|
||||
settings: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Document preferences updated`),
|
||||
description: _(msg`Your document preferences have been updated`),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong!`),
|
||||
description: _(
|
||||
msg`We were unable to update your document preferences at this time, please try again later`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentVisibility"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Visibility</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Only managers and above can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Only admins can access and view the document</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentLanguage"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Document Language</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{language.full}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the default language of an uploaded document. This will be used as the
|
||||
language in email communications with the recipients.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
<Trans>Preview</Trans>
|
||||
</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>
|
||||
)}
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the formatting of the message that will be sent when inviting a
|
||||
recipient to sign a document. If a custom message has been provided while
|
||||
configuring the document, it will be used instead.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { TeamBrandingPreferencesForm } from './branding-preferences';
|
||||
import { TeamDocumentPreferencesForm } from './document-preferences';
|
||||
|
||||
export type TeamsSettingsPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { teamUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Team Preferences`)}
|
||||
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||
</section>
|
||||
|
||||
<SettingsHeader
|
||||
title={_(msg`Branding Preferences`)}
|
||||
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
|
||||
className="mt-8"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { TemplateEditPageViewProps } from '~/app/(dashboard)/templates/[id]/edit/template-edit-page-view';
|
||||
import { TemplateEditPageView } from '~/app/(dashboard)/templates/[id]/edit/template-edit-page-view';
|
||||
|
||||
export type TeamsTemplateEditPageProps = {
|
||||
params: TemplateEditPageViewProps['params'] & {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsTemplateEditPage({ params }: TeamsTemplateEditPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <TemplateEditPageView params={params} team={team} />;
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
const ZIndexes: { [key: string]: string } = {
|
||||
@ -12,7 +13,7 @@ export type StackAvatarProps = {
|
||||
first?: boolean;
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||
type: RecipientStatusType;
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
@ -24,19 +25,24 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
zIndexClass = ZIndexes[zIndex] ?? '';
|
||||
}
|
||||
|
||||
console.log({ type, fallbackText });
|
||||
|
||||
switch (type) {
|
||||
case 'unsigned':
|
||||
case RecipientStatusType.UNSIGNED:
|
||||
classes = 'bg-dawn-200 text-dawn-900';
|
||||
break;
|
||||
case 'opened':
|
||||
case RecipientStatusType.OPENED:
|
||||
classes = 'bg-yellow-200 text-yellow-700';
|
||||
break;
|
||||
case 'waiting':
|
||||
case RecipientStatusType.WAITING:
|
||||
classes = 'bg-water text-water-700';
|
||||
break;
|
||||
case 'completed':
|
||||
case RecipientStatusType.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, Recipient } from '@documenso/prisma/client';
|
||||
import { type DocumentStatus, type Recipient } from '@documenso/prisma/client';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
@ -46,7 +46,22 @@ export const StackAvatarsWithTooltip = ({
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => recipients.sort((a, b) => a.id - b.id), [recipients]);
|
||||
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]);
|
||||
|
||||
return (
|
||||
<PopoverHover
|
||||
@ -80,6 +95,30 @@ 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,18 +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();
|
||||
|
||||
@ -49,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,
|
||||
});
|
||||
@ -73,6 +111,8 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
form.reset({
|
||||
name,
|
||||
url,
|
||||
documentVisibility,
|
||||
includeSenderDetails,
|
||||
});
|
||||
|
||||
if (url !== teamUrl) {
|
||||
@ -146,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 && (
|
||||
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Settings, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2Icon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@ -26,6 +26,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
@ -44,6 +45,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link href={publicProfilePath}>
|
||||
<Button
|
||||
|
||||
@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Key, User, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2Icon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@ -26,6 +26,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
@ -52,6 +53,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link href={publicProfilePath}>
|
||||
<Button
|
||||
|
||||
@ -12,7 +12,7 @@ import { UAParser } from 'ua-parser-js';
|
||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
@ -37,7 +37,7 @@ export const DocumentHistorySheet = ({
|
||||
onMenuOpenChange,
|
||||
children,
|
||||
}: DocumentHistorySheetProps) => {
|
||||
const { i18n } = useLingui();
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
|
||||
|
||||
@ -152,7 +152,7 @@ export const DocumentHistorySheet = ({
|
||||
|
||||
<div>
|
||||
<p className="text-foreground text-xs font-bold">
|
||||
{formatDocumentAuditLogActionString(auditLog, userId)}
|
||||
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
</p>
|
||||
<p className="text-foreground/50 text-xs">
|
||||
{DateTime.fromJSDate(auditLog.createdAt)
|
||||
@ -169,6 +169,7 @@ export const DocumentHistorySheet = ({
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
|
||||
{ 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,
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { EyeOffIcon } from 'lucide-react';
|
||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -18,8 +19,10 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { DocumentMeta } from '@documenso/prisma/client';
|
||||
import { FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
@ -27,9 +30,14 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: DocumentField[];
|
||||
documentMeta?: DocumentMeta;
|
||||
showFieldStatus?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
|
||||
export const DocumentReadOnlyFields = ({
|
||||
documentMeta,
|
||||
fields,
|
||||
showFieldStatus = true,
|
||||
}: DocumentReadOnlyFieldsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
||||
@ -58,15 +66,37 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'relative flex w-fit flex-col p-2.5 text-sm',
|
||||
className: 'relative flex w-fit flex-col p-4 text-sm',
|
||||
}}
|
||||
>
|
||||
<p className="font-semibold">
|
||||
{field.Recipient.signingStatus === SigningStatus.SIGNED ? 'Signed' : 'Pending'}{' '}
|
||||
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type]).toLowerCase()} field
|
||||
{showFieldStatus && (
|
||||
<Badge
|
||||
className="mx-auto mb-1 py-0.5"
|
||||
variant={
|
||||
field.Recipient.signingStatus === SigningStatus.SIGNED
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{field.Recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<p className="text-center font-semibold">
|
||||
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||
{field.Recipient.name
|
||||
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||
: field.Recipient.email}{' '}
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentRecipientLinkCopyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export const DocumentRecipientLinkCopyDialog = ({
|
||||
trigger,
|
||||
recipients,
|
||||
}: DocumentRecipientLinkCopyDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
|
||||
const onBulkCopy = async () => {
|
||||
const generatedString = recipients
|
||||
.filter((recipient) => recipient.role !== RecipientRole.CC)
|
||||
.map((recipient) => `${recipient.email}\n${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`)
|
||||
.join('\n\n');
|
||||
|
||||
await copy(generatedString).then(() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`All signing links have been copied to your clipboard.`),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (actionSearchParam === 'view-signing-links') {
|
||||
setOpen(true);
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}, [actionSearchParam, open, setOpen, updateSearchParams]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
|
||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pb-0.5">
|
||||
<Trans>Copy Signing Links</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You can copy and share these links to recipients so they can action the document.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="text-muted-foreground divide-y rounded-lg border">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
{recipient.role !== RecipientRole.CC && (
|
||||
<CopyTextButton
|
||||
value={formatSigningLink(recipient.token)}
|
||||
onCopySuccess={() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
}}
|
||||
badgeContentUncopied={
|
||||
<p className="ml-1 text-xs">
|
||||
<Trans>Copy</Trans>
|
||||
</p>
|
||||
}
|
||||
badgeContentCopied={
|
||||
<p className="ml-1 text-xs">
|
||||
<Trans>Copied</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" onClick={onBulkCopy}>
|
||||
<Trans>Bulk Copy</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
50
apps/web/src/components/forms/search-param-selector.tsx
Normal file
50
apps/web/src/components/forms/search-param-selector.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
|
||||
export type SearchParamSelector = {
|
||||
paramKey: string;
|
||||
isValueValid: (value: unknown) => boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const value = useMemo(() => {
|
||||
const p = searchParams?.get(paramKey) ?? 'all';
|
||||
|
||||
return isValueValid(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set(paramKey, newValue);
|
||||
|
||||
if (newValue === '' || newValue === 'all') {
|
||||
params.delete(paramKey);
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">{children}</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
59
apps/web/src/pages/api/branding/logo/team/[teamId].ts
Normal file
59
apps/web/src/pages/api/branding/logo/team/[teamId].ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const teamId = Number(req.query['teamId']);
|
||||
|
||||
if (teamId === 0 || Number.isNaN(teamId)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: 'Invalid team ID',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.teamGlobalSettings.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings || !settings.brandingEnabled) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.brandingLogo) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
|
||||
|
||||
if (!file) {
|
||||
return res.status(404).json({
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
});
|
||||
}
|
||||
|
||||
const img = await sharp(file)
|
||||
.toFormat('png', {
|
||||
quality: 80,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Length', img.length);
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
|
||||
|
||||
res.status(200).send(img);
|
||||
}
|
||||
@ -50,6 +50,7 @@ services:
|
||||
- NEXT_PRIVATE_SMTP_SECURE=${NEXT_PRIVATE_SMTP_SECURE}
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME=${NEXT_PRIVATE_SMTP_FROM_NAME:?err}
|
||||
- NEXT_PRIVATE_SMTP_FROM_ADDRESS=${NEXT_PRIVATE_SMTP_FROM_ADDRESS:?err}
|
||||
- NEXT_PRIVATE_SMTP_SERVICE=${NEXT_PRIVATE_SMTP_SERVICE}
|
||||
- NEXT_PRIVATE_RESEND_API_KEY=${NEXT_PRIVATE_RESEND_API_KEY}
|
||||
- NEXT_PRIVATE_MAILCHANNELS_API_KEY=${NEXT_PRIVATE_MAILCHANNELS_API_KEY}
|
||||
- NEXT_PRIVATE_MAILCHANNELS_ENDPOINT=${NEXT_PRIVATE_MAILCHANNELS_ENDPOINT}
|
||||
@ -60,6 +61,7 @@ services:
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
|
||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
||||
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
|
||||
ports:
|
||||
- ${PORT:-3000}:${PORT:-3000}
|
||||
volumes:
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.7.2-rc.4",
|
||||
"version": "1.8.0-rc.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.7.2-rc.4",
|
||||
"version": "1.8.0-rc.4",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -80,7 +80,7 @@
|
||||
},
|
||||
"apps/marketing": {
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.7.2-rc.4",
|
||||
"version": "1.8.0-rc.4",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/assets": "*",
|
||||
@ -441,7 +441,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@documenso/web",
|
||||
"version": "1.7.2-rc.4",
|
||||
"version": "1.8.0-rc.4",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.7.2-rc.4",
|
||||
"version": "1.8.0-rc.4",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
|
||||
@ -168,6 +168,9 @@ 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: {
|
||||
|
||||
@ -67,7 +67,10 @@ export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocume
|
||||
|
||||
export const ZSendDocumentForSigningMutationSchema = z
|
||||
.object({
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
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.',
|
||||
}),
|
||||
})
|
||||
.or(z.literal('').transform(() => ({ sendEmail: true })));
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
@ -93,7 +93,7 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
@ -262,7 +262,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
||||
}
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
@ -373,7 +373,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
||||
}
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
|
||||
@ -106,7 +106,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
@ -190,7 +190,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
@ -287,7 +287,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
@ -566,7 +566,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
@ -462,6 +462,45 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
|
||||
}
|
||||
});
|
||||
|
||||
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
// Seed a member user
|
||||
const memberUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
// Seed a document with ADMIN visibility but make the member user a recipient
|
||||
await seedDocuments([
|
||||
{
|
||||
sender: memberUser,
|
||||
recipients: [],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
teamId: team.id,
|
||||
visibility: 'ADMIN',
|
||||
title: 'Admin Document with Member Document Owner',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
|
||||
});
|
||||
|
||||
// Check that the member user can see the document
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Admin Document with Member Document Owner', exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[TEAMS]: ensure recipient can see document regardless of visibility', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
|
||||
62
packages/app-tests/e2e/teams/team-global-settings.spec.ts
Normal file
62
packages/app-tests/e2e/teams/team-global-settings.spec.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: update the default document visibility in the team global settings', async ({
|
||||
page,
|
||||
}) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Admin' }).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('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 }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
const checkbox = page.getByLabel('Send on Behalf of Team');
|
||||
await checkbox.check();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
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('Success', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
toast.getByText('Your team has been successfully updated.', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
@ -29,7 +29,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
|
||||
await page.getByLabel('Confirm by typing transfer').fill('transfer');
|
||||
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||
|
||||
await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
|
||||
await expect(page.locator('[id*="form-item-message"]').first()).toContainText(
|
||||
`You must enter 'transfer ${team.name}' to proceed`,
|
||||
);
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ test.describe('[EE_ONLY]', () => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set EE action auth.
|
||||
@ -74,7 +74,7 @@ test.describe('[EE_ONLY]', () => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamMemberUser.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set EE action auth.
|
||||
@ -110,7 +110,7 @@ test.describe('[EE_ONLY]', () => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamMemberUser.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Global action auth should not be visible.
|
||||
@ -132,7 +132,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set title.
|
||||
|
||||
@ -31,7 +31,7 @@ test.describe('[EE_ONLY]', () => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
@ -81,7 +81,7 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
|
||||
@ -37,7 +37,7 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}`,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
@ -172,7 +172,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
|
||||
@ -18,7 +18,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (box) {
|
||||
|
||||
@ -12,7 +12,7 @@ test('[USER] update full name', async ({ page }) => {
|
||||
|
||||
await page.getByLabel('Full Name').fill('John Doe');
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const canvas = page.locator('canvas').first();
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (box) {
|
||||
|
||||
@ -1,10 +1,56 @@
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { createTransport } from 'nodemailer';
|
||||
|
||||
import { ResendTransport } from '@documenso/nodemailer-resend';
|
||||
|
||||
import { MailChannelsTransport } from './transports/mailchannels';
|
||||
|
||||
const getTransport = () => {
|
||||
/**
|
||||
* Creates a Nodemailer transport object for sending emails.
|
||||
*
|
||||
* This function uses various environment variables to configure the appropriate
|
||||
* email transport mechanism. It supports multiple types of email transports,
|
||||
* including MailChannels, Resend, and different SMTP configurations.
|
||||
*
|
||||
* @returns {Transporter} A configured Nodemailer transporter instance.
|
||||
*
|
||||
* Supported Transports:
|
||||
* - **mailchannels**: Uses MailChannelsTransport, requiring:
|
||||
* - `NEXT_PRIVATE_MAILCHANNELS_API_KEY`: API key for MailChannels
|
||||
* - `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT`: Endpoint for MailChannels (optional)
|
||||
* - **resend**: Uses ResendTransport, requiring:
|
||||
* - `NEXT_PRIVATE_RESEND_API_KEY`: API key for Resend
|
||||
* - **smtp-api**: Uses a custom SMTP API configuration, requiring:
|
||||
* - `NEXT_PRIVATE_SMTP_HOST`: The SMTP server host
|
||||
* - `NEXT_PRIVATE_SMTP_APIKEY`: The API key for SMTP authentication
|
||||
* - `NEXT_PRIVATE_SMTP_APIKEY_USER`: The username for SMTP authentication (default: 'apikey')
|
||||
* - **smtp-auth** (default): Uses a standard SMTP configuration, requiring:
|
||||
* - `NEXT_PRIVATE_SMTP_HOST`: The SMTP server host (default: 'localhost:2500')
|
||||
* - `NEXT_PRIVATE_SMTP_PORT`: The port to connect to (default: 587)
|
||||
* - `NEXT_PRIVATE_SMTP_SECURE`: Whether to use SSL/TLS (default: false)
|
||||
* - `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS`: Whether to ignore TLS (default: false)
|
||||
* - `NEXT_PRIVATE_SMTP_USERNAME`: The username for SMTP authentication
|
||||
* - `NEXT_PRIVATE_SMTP_PASSWORD`: The password for SMTP authentication
|
||||
* - `NEXT_PRIVATE_SMTP_SERVICE`: The SMTP service provider (e.g., "gmail"). This option is used
|
||||
* when integrating with well-known services (like Gmail), enabling simplified configuration.
|
||||
*
|
||||
* Example Usage:
|
||||
* ```env
|
||||
* NEXT_PRIVATE_SMTP_TRANSPORT='smtp-auth';
|
||||
* NEXT_PRIVATE_SMTP_HOST='smtp.example.com';
|
||||
* NEXT_PRIVATE_SMTP_PORT=587;
|
||||
* NEXT_PRIVATE_SMTP_SERVICE='gmail';
|
||||
* NEXT_PRIVATE_SMTP_SECURE='true';
|
||||
* NEXT_PRIVATE_SMTP_USERNAME='your-email@gmail.com';
|
||||
* NEXT_PRIVATE_SMTP_PASSWORD='your-password';
|
||||
* ```
|
||||
*
|
||||
* Notes:
|
||||
* - Ensure that the required environment variables for each transport type are set.
|
||||
* - If `NEXT_PRIVATE_SMTP_TRANSPORT` is not specified, the default is `smtp-auth`.
|
||||
* - `NEXT_PRIVATE_SMTP_SERVICE` is optional and used specifically for well-known services like Gmail.
|
||||
*/
|
||||
const getTransport = (): Transporter => {
|
||||
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
|
||||
|
||||
if (transport === 'mailchannels') {
|
||||
@ -53,6 +99,9 @@ const getTransport = () => {
|
||||
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
|
||||
}
|
||||
: undefined,
|
||||
...(process.env.NEXT_PRIVATE_SMTP_SERVICE
|
||||
? { service: process.env.NEXT_PRIVATE_SMTP_SERVICE }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
44
packages/email/providers/branding.tsx
Normal file
44
packages/email/providers/branding.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
type BrandingContextValue = {
|
||||
brandingEnabled: boolean;
|
||||
brandingUrl: string;
|
||||
brandingLogo: string;
|
||||
brandingCompanyDetails: string;
|
||||
brandingHidePoweredBy: boolean;
|
||||
};
|
||||
|
||||
const BrandingContext = createContext<BrandingContextValue | undefined>(undefined);
|
||||
|
||||
const defaultBrandingContextValue: BrandingContextValue = {
|
||||
brandingEnabled: false,
|
||||
brandingUrl: '',
|
||||
brandingLogo: '',
|
||||
brandingCompanyDetails: '',
|
||||
brandingHidePoweredBy: false,
|
||||
};
|
||||
|
||||
export const BrandingProvider = (props: {
|
||||
branding?: BrandingContextValue;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<BrandingContext.Provider value={props.branding ?? defaultBrandingContextValue}>
|
||||
{props.children}
|
||||
</BrandingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBranding = () => {
|
||||
const ctx = useContext(BrandingContext);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Branding context not found');
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export type BrandingSettings = BrandingContextValue;
|
||||
@ -1,11 +1,18 @@
|
||||
import * as reactEmail from '@react-email/render';
|
||||
import * as ReactEmail from '@react-email/render';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import { Tailwind } from './components';
|
||||
import { BrandingProvider, type BrandingSettings } from './providers/branding';
|
||||
|
||||
export const render: typeof reactEmail.render = (element, options) => {
|
||||
return reactEmail.render(
|
||||
export type RenderOptions = ReactEmail.Options & {
|
||||
branding?: BrandingSettings;
|
||||
};
|
||||
|
||||
export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return ReactEmail.render(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
@ -15,14 +22,16 @@ export const render: typeof reactEmail.render = (element, options) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
options,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
export const renderAsync: typeof reactEmail.renderAsync = async (element, options) => {
|
||||
return reactEmail.renderAsync(
|
||||
export const renderAsync = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return await ReactEmail.renderAsync(
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
@ -32,8 +41,8 @@ export const renderAsync: typeof reactEmail.renderAsync = async (element, option
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
<BrandingProvider branding={branding}>{element}</BrandingProvider>
|
||||
</Tailwind>,
|
||||
options,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
@ -13,8 +12,6 @@ export const TemplateConfirmationEmail = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
@ -38,7 +38,7 @@ export const TemplateDocumentCompleted = ({
|
||||
</Section>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
<Trans>{customBody ?? `“${documentName}” was signed by all signers`}</Trans>
|
||||
{customBody || <Trans>“{documentName}” was signed by all signers</Trans>}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
@ -46,13 +46,6 @@ export const TemplateDocumentCompleted = ({
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
{/* <Button
|
||||
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={reviewLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
|
||||
Review
|
||||
</Button> */}
|
||||
<Button
|
||||
className="rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
|
||||
href={downloadLink}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
@ -17,6 +20,7 @@ export interface TemplateDocumentInviteProps {
|
||||
selfSigner: boolean;
|
||||
isTeamInvite: boolean;
|
||||
teamName?: string;
|
||||
includeSenderDetails?: boolean;
|
||||
}
|
||||
|
||||
export const TemplateDocumentInvite = ({
|
||||
@ -28,10 +32,19 @@ export const TemplateDocumentInvite = ({
|
||||
selfSigner,
|
||||
isTeamInvite,
|
||||
teamName,
|
||||
includeSenderDetails,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION_ENG[role];
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
const rejectDocumentLink = useMemo(() => {
|
||||
const url = new URL(signDocumentLink);
|
||||
|
||||
url.searchParams.set('reject', 'true');
|
||||
|
||||
return url.toString();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -41,37 +54,58 @@ export const TemplateDocumentInvite = ({
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{selfSigner ? (
|
||||
<Trans>
|
||||
{`Please ${_(actionVerb).toLowerCase()} your document`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
) : isTeamInvite ? (
|
||||
<Trans>
|
||||
{`${inviterName} on behalf of ${teamName} has invited you to ${_(
|
||||
actionVerb,
|
||||
).toLowerCase()}`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
</Trans>
|
||||
<>
|
||||
{includeSenderDetails ? (
|
||||
<Trans>
|
||||
{inviterName} on behalf of {teamName} has invited you to{' '}
|
||||
{_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
)}
|
||||
<br />"{documentName}"
|
||||
</>
|
||||
) : (
|
||||
<Trans>
|
||||
{`${inviterName} has invited you to ${_(actionVerb).toLowerCase()}`}
|
||||
<br />
|
||||
{`"${documentName}"`}
|
||||
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>Continue by {_(progressiveVerb).toLowerCase()} the document.</Trans>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.exhaustive()}
|
||||
</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}
|
||||
>
|
||||
<Trans>{_(actionVerb)} Document</Trans>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
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,15 +1,18 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { Link, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
|
||||
export type TemplateFooterProps = {
|
||||
isDocument?: boolean;
|
||||
};
|
||||
|
||||
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
const branding = useBranding();
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{isDocument && (
|
||||
{isDocument && !branding.brandingHidePoweredBy && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
<Trans>
|
||||
This document was sent using{' '}
|
||||
@ -20,11 +23,24 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso, Inc.
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
{branding.brandingCompanyDetails ? (
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
{branding.brandingCompanyDetails.split('\n').map((line, idx) => {
|
||||
return (
|
||||
<>
|
||||
{idx > 0 && <br />}
|
||||
{line}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso, Inc.
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ 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 type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
|
||||
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
@ -11,6 +12,7 @@ export const ConfirmEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Please confirm your email address`;
|
||||
|
||||
@ -26,11 +28,15 @@ export const ConfirmEmailTemplate = ({
|
||||
<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>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateConfirmationEmail
|
||||
confirmationLink={confirmationLink}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
@ -10,11 +10,13 @@ import {
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
@ -34,6 +36,7 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
token = '',
|
||||
}: ConfirmTeamEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Accept team email request for ${teamName} on Documenso`;
|
||||
|
||||
@ -45,11 +48,15 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
@ -61,12 +68,14 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Verify your team email address
|
||||
<Trans>Verify your team email address</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-base">
|
||||
<span className="font-bold">{teamName}</span> has requested to use your email
|
||||
address for their team on Documenso.
|
||||
<Trans>
|
||||
<span className="font-bold">{teamName}</span> has requested to use your email
|
||||
address for their team on Documenso.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
@ -75,25 +84,29 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
|
||||
<Section className="mt-6">
|
||||
<Text className="my-0 text-sm">
|
||||
By accepting this request, you will be granting <strong>{teamName}</strong> access
|
||||
to:
|
||||
<Trans>
|
||||
By accepting this request, you will be granting <strong>{teamName}</strong>{' '}
|
||||
access to:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="mb-0 mt-2">
|
||||
<li className="text-sm">
|
||||
View all documents sent to and from this email address
|
||||
<Trans>View all documents sent to and from this email address</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
Allow document recipients to reply directly to this email address
|
||||
<Trans>Allow document recipients to reply directly to this email address</Trans>
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
Send documents on behalf of the team using the email address
|
||||
<Trans>Send documents on behalf of the team using the email address</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Text className="mt-2 text-sm">
|
||||
You can revoke access at any time in your team settings on Documenso{' '}
|
||||
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
|
||||
<Trans>
|
||||
You can revoke access at any time in your team settings on Documenso{' '}
|
||||
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@ -102,12 +115,14 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
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={`${baseUrl}/team/verify/email/${token}`}
|
||||
>
|
||||
Accept
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs text-slate-500">Link expires in 1 hour.</Text>
|
||||
<Text className="text-center text-xs text-slate-500">
|
||||
<Trans>Link expires in 1 hour.</Trans>
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user