mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 21:12:48 +10:00
Compare commits
39 Commits
v1.7.2-rc.
...
v1.8.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e4ab7c56 | |||
| 23a0537648 | |||
| f6bcf921d5 | |||
| 451723a8ab | |||
| 9b769e7e33 | |||
| 61ea4971ad | |||
| ffc61af904 | |||
| efbe94aea8 | |||
| 1b10c55758 | |||
| 3da4603a47 | |||
| dcc2ac8a71 | |||
| 5158584955 | |||
| 54c0c6be14 | |||
| 927a24249c | |||
| a50c758b07 | |||
| cc249357b3 | |||
| 011dabcc04 | |||
| 4fa6dc1e21 | |||
| 32b65c4d49 | |||
| de880aa821 | |||
| dc5723c386 | |||
| c57d1dc55d | |||
| 4dd95016b1 | |||
| 04b1ce1aab | |||
| 885349ad94 | |||
| 28514ba2e7 | |||
| 8aa6d8e602 | |||
| 378e515843 | |||
| f42e600e3f | |||
| 88eaec91c9 | |||
| f199183c78 | |||
| 0cee07aed3 | |||
| f76f87ff1c | |||
| 6020336792 | |||
| 634b30aa54 | |||
| 7fc497a642 | |||
| e30ceeb038 | |||
| 872762661a | |||
| 5fcd8610c9 |
@ -93,6 +93,8 @@ NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=
|
|||||||
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
||||||
# REQUIRED: Defines the email address to use as the from address.
|
# REQUIRED: Defines the email address to use as the from address.
|
||||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
||||||
|
# OPTIONAL: Defines the service for nodemailer
|
||||||
|
NEXT_PRIVATE_SMTP_SERVICE=
|
||||||
# OPTIONAL: The API key to use for Resend.com
|
# OPTIONAL: The API key to use for Resend.com
|
||||||
NEXT_PRIVATE_RESEND_API_KEY=
|
NEXT_PRIVATE_RESEND_API_KEY=
|
||||||
# OPTIONAL: The API key to use for MailChannels.
|
# OPTIONAL: The API key to use for MailChannels.
|
||||||
|
|||||||
2
.github/workflows/translations-upload.yml
vendored
2
.github/workflows/translations-upload.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
token: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
- uses: ./.github/actions/node-install
|
- uses: ./.github/actions/node-install
|
||||||
|
|
||||||
|
|||||||
@ -38,11 +38,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:
|
Combine the private key and the self-signed certificate to create a `.p12` certificate. Use the following command:
|
||||||
|
|
||||||
```bash
|
```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">
|
<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>
|
</Callout>
|
||||||
|
|
||||||
@ -54,8 +60,8 @@ Note that for local development, the password can be left empty.
|
|||||||
|
|
||||||
### Add Certificate to the Project
|
### 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>
|
</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:
|
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
||||||
|
|
||||||
```bash
|
```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.
|
The command will start the PostgreSQL database and the Documenso application containers.
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"direct-links": "Direct Signing Links",
|
"direct-links": "Direct Signing Links",
|
||||||
"document-visibility": "Document Visibility",
|
"document-visibility": "Document Visibility",
|
||||||
|
"teams": "Teams",
|
||||||
"-- Legal Overview": {
|
"-- Legal Overview": {
|
||||||
"type": "separator",
|
"type": "separator",
|
||||||
"title": "Legal Overview"
|
"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 |
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/marketing",
|
"name": "@documenso/marketing",
|
||||||
"version": "1.7.2-rc.1",
|
"version": "1.8.0-rc.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3001",
|
"dev": "next dev -p 3001",
|
||||||
"build": "turbo run translate:extract && turbo run translate:compile && next build",
|
"build": "npm run translate:extract --prefix ../../ && turbo run translate:compile && next build",
|
||||||
"start": "next start -p 3001",
|
"start": "next start -p 3001",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
|
|||||||
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 { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { allDocuments } from 'contentlayer/generated';
|
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 { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
|
import { ContentPageContent } from './content';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||||
@ -19,19 +18,13 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
|
|||||||
return { title: document.title };
|
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.
|
* A generic catch all page for the root level that checks for content layer documents.
|
||||||
*
|
*
|
||||||
* Will render the document if it exists, otherwise will return a 404.
|
* Will render the document if it exists, otherwise will return a 404.
|
||||||
*/
|
*/
|
||||||
export default function ContentPage({ params }: { params: { content: string } }) {
|
export default async function ContentPage({ params }: { params: { content: string } }) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);
|
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);
|
||||||
|
|
||||||
@ -39,11 +32,9 @@ export default function ContentPage({ params }: { params: { content: string } })
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const MDXContent = useMDXComponent(post.body.code);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="prose dark:prose-invert mx-auto">
|
<article className="prose dark:prose-invert mx-auto">
|
||||||
<MDXContent components={mdxComponents} />
|
<ContentPageContent document={post} />
|
||||||
</article>
|
</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 Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { allBlogPosts } from 'contentlayer/generated';
|
import { allBlogPosts } from 'contentlayer/generated';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
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 { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
||||||
|
|
||||||
|
import { BlogPostContent } from './content';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||||
@ -42,14 +41,8 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mdxComponents: MDXComponents = {
|
export default async function BlogPostPage({ params }: { params: { post: string } }) {
|
||||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
await setupI18nSSR();
|
||||||
<Image {...props} alt={props.alt ?? ''} />
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BlogPostPage({ params }: { params: { post: string } }) {
|
|
||||||
setupI18nSSR();
|
|
||||||
|
|
||||||
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||||
|
|
||||||
@ -57,8 +50,6 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const MDXContent = useMDXComponent(post.body.code);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<article className="prose dark:prose-invert mx-auto py-8">
|
<article className="prose dark:prose-invert mx-auto py-8">
|
||||||
@ -87,7 +78,7 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MDXContent components={mdxComponents} />
|
<BlogPostContent post={post} />
|
||||||
|
|
||||||
{post.tags.length > 0 && (
|
{post.tags.length > 0 && (
|
||||||
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
|
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
|
||||||
|
|||||||
@ -9,8 +9,8 @@ export const metadata: Metadata = {
|
|||||||
title: 'Blog',
|
title: 'Blog',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default async function BlogPage() {
|
||||||
const { i18n } = setupI18nSSR();
|
const { i18n } = await setupI18nSSR();
|
||||||
|
|
||||||
const blogPosts = allBlogPosts.sort((a, b) => {
|
const blogPosts = allBlogPosts.sort((a, b) => {
|
||||||
const dateA = new Date(a.date);
|
const dateA = new Date(a.date);
|
||||||
|
|||||||
@ -131,7 +131,7 @@ const fetchEarlyAdopters = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function OpenPage() {
|
export default async function OpenPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ const fontCaveat = Caveat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default async function IndexPage() {
|
export default async function IndexPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const starCount = await fetch('https://api.github.com/repos/documenso/documenso', {
|
const starCount = await fetch('https://api.github.com/repos/documenso/documenso', {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -30,8 +30,8 @@ export type PricingPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default async function PricingPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 sm:mt-12">
|
<div className="mt-6 sm:mt-12">
|
||||||
|
|||||||
@ -14,8 +14,8 @@ export const dynamic = 'force-dynamic';
|
|||||||
// !: This entire file is a hack to get around failed prerendering of
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
// !: the Single Player Mode page. This regression was introduced during
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
// !: the upgrade of Next.js to v13.5.x.
|
// !: the upgrade of Next.js to v13.5.x.
|
||||||
export default function SingleplayerPage() {
|
export default async function SingleplayerPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return <SinglePlayerClient />;
|
return <SinglePlayerClient />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export function generateMetadata() {
|
|||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const flags = await getAllAnonymousFlags();
|
const flags = await getAllAnonymousFlags();
|
||||||
|
|
||||||
const { lang, locales, i18n } = setupI18nSSR();
|
const { lang, locales, i18n } = await setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "1.7.2-rc.1",
|
"version": "1.8.0-rc.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"build": "turbo run translate:extract && turbo run translate:compile && next build",
|
"build": "npm run translate:extract --prefix ../../ && turbo run translate:compile && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"e2e:prepare": "next build && next start",
|
"e2e:prepare": "next build && next start",
|
||||||
|
|||||||
@ -24,7 +24,7 @@ type AdminDocumentDetailsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
|
||||||
const { i18n } = setupI18nSSR();
|
const { i18n } = await setupI18nSSR();
|
||||||
|
|
||||||
const document = await getEntireDocument({ id: Number(params.id) });
|
const document = await getEntireDocument({ id: Number(params.id) });
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
|||||||
|
|
||||||
import { AdminDocumentResults } from './document-results';
|
import { AdminDocumentResults } from './document-results';
|
||||||
|
|
||||||
export default function AdminDocumentsPage() {
|
export default async function AdminDocumentsPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export type AdminSectionLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { BannerForm } from './banner-form';
|
|||||||
// import { BannerForm } from './banner-form';
|
// import { BannerForm } from './banner-form';
|
||||||
|
|
||||||
export default async function AdminBannerPage() {
|
export default async function AdminBannerPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import { SignerConversionChart } from './signer-conversion-chart';
|
|||||||
import { UserWithDocumentChart } from './user-with-document';
|
import { UserWithDocumentChart } from './user-with-document';
|
||||||
|
|
||||||
export default async function AdminStatsPage() {
|
export default async function AdminStatsPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/table';
|
} from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
export default async function Subscriptions() {
|
export default async function Subscriptions() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const subscriptions = await findSubscriptions();
|
const subscriptions = await findSubscriptions();
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ type AdminManageUsersProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 10;
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
|
|||||||
@ -33,6 +33,8 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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 { ResendDocumentActionItem } from '../_action-items/resend-document';
|
||||||
import { DeleteDocumentDialog } from '../delete-document-dialog';
|
import { DeleteDocumentDialog } from '../delete-document-dialog';
|
||||||
import { DuplicateDocumentDialog } from '../duplicate-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 isOwner = document.User.id === session.user.id;
|
||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||||
|
const isPending = document.status === DocumentStatus.PENDING;
|
||||||
const isDeleted = document.deletedAt !== null;
|
const isDeleted = document.deletedAt !== null;
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
@ -145,6 +148,21 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
<Trans>Share</Trans>
|
<Trans>Share</Trans>
|
||||||
</DropdownMenuLabel>
|
</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
|
<ResendDocumentActionItem
|
||||||
document={document}
|
document={document}
|
||||||
recipients={nonSignedRecipients}
|
recipients={nonSignedRecipients}
|
||||||
|
|||||||
@ -143,17 +143,11 @@ export const DocumentPageViewRecentActivity = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Todo: Translations. */}
|
|
||||||
<p
|
<p
|
||||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||||
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
|
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||||
formatDocumentAuditLogAction(auditLog, userId).description
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span className="text-foreground font-medium">
|
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||||
{formatDocumentAuditLogAction(auditLog, userId).prefix}
|
|
||||||
</span>{' '}
|
|
||||||
{formatDocumentAuditLogAction(auditLog, userId).description}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
@ -6,11 +8,14 @@ import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'luc
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
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 { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { Document, Recipient } 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 { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentPageViewRecipientsProps = {
|
export type DocumentPageViewRecipientsProps = {
|
||||||
document: Document & {
|
document: Document & {
|
||||||
@ -24,6 +29,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: DocumentPageViewRecipientsProps) => {
|
}: DocumentPageViewRecipientsProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const recipients = document.Recipient;
|
const recipients = document.Recipient;
|
||||||
|
|
||||||
@ -68,53 +74,69 @@ export const DocumentPageViewRecipients = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
<div className="flex flex-row items-center">
|
||||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
{document.status !== DocumentStatus.DRAFT &&
|
||||||
<Badge variant="default">
|
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||||
{match(recipient.role)
|
<Badge variant="default">
|
||||||
.with(RecipientRole.APPROVER, () => (
|
{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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="mr-1 h-3 w-3" />
|
<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, () => (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<>
|
<>
|
||||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||||
<Trans>Signed</Trans>
|
<Trans>Signed</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<>
|
<>
|
||||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||||
<Trans>Viewed</Trans>
|
<Trans>Viewed</Trans>
|
||||||
</>
|
</>
|
||||||
))
|
))
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status !== DocumentStatus.DRAFT &&
|
{document.status !== DocumentStatus.DRAFT &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
<Trans>Pending</Trans>
|
<Trans>Pending</Trans>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
|
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||||
import {
|
import {
|
||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
FRIENDLY_STATUS_MAP,
|
FRIENDLY_STATUS_MAP,
|
||||||
@ -73,7 +74,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
|
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
|
||||||
let canAccessDocument = true;
|
let canAccessDocument = true;
|
||||||
|
|
||||||
if (team && !isRecipient) {
|
if (team && !isRecipient && document?.userId !== user.id) {
|
||||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||||
@ -134,6 +135,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
{document.status === DocumentStatus.PENDING && (
|
||||||
|
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
|
|||||||
@ -7,10 +7,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { msg } from '@lingui/macro';
|
import { msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
} from '@documenso/lib/constants/trpc';
|
} from '@documenso/lib/constants/trpc';
|
||||||
|
import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -176,8 +178,8 @@ export const EditDocumentForm = ({
|
|||||||
stepIndex: 3,
|
stepIndex: 3,
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
title: msg`Add Subject`,
|
title: msg`Distribute Document`,
|
||||||
description: msg`Add the subject and message you wish to send to signers.`,
|
description: msg`Choose how the document will reach recipients`,
|
||||||
stepIndex: 4,
|
stepIndex: 4,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -201,7 +203,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl } = data.meta;
|
const { timezone, dateFormat, redirectUrl, language } = data.meta;
|
||||||
|
|
||||||
await setSettingsForDocument({
|
await setSettingsForDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@ -217,6 +219,7 @@ export const EditDocumentForm = ({
|
|||||||
timezone,
|
timezone,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
|
language: isValidLanguageCode(language) ? language : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -305,7 +308,7 @@ export const EditDocumentForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message } = data.meta;
|
const { subject, message, distributionMethod, emailSettings } = data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
@ -314,16 +317,31 @@ export const EditDocumentForm = ({
|
|||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
|
distributionMethod,
|
||||||
|
emailSettings,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
if (distributionMethod === DocumentDistributionMethod.EMAIL) {
|
||||||
title: _(msg`Document sent`),
|
toast({
|
||||||
description: _(msg`Your document has been sent successfully.`),
|
title: _(msg`Document sent`),
|
||||||
duration: 5000,
|
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) {
|
} catch (err) {
|
||||||
console.error(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);
|
const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email);
|
||||||
let canAccessDocument = true;
|
let canAccessDocument = true;
|
||||||
|
|
||||||
if (!isRecipient) {
|
if (!isRecipient && document?.userId !== user.id) {
|
||||||
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||||
|
|||||||
@ -8,8 +8,8 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
export default async function DocumentEditPage({ params }: DocumentPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentEditPageView params={params} />;
|
return <DocumentEditPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { ChevronLeft, Loader } from 'lucide-react';
|
|||||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
|
||||||
export default function Loading() {
|
export default async function Loading() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
|
|||||||
@ -58,10 +58,6 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const uppercaseFistLetter = (text: string) => {
|
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
const results = data ?? {
|
||||||
data: [],
|
data: [],
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
@ -103,9 +99,7 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
|||||||
{
|
{
|
||||||
header: _(msg`Action`),
|
header: _(msg`Action`),
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
||||||
<span>{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'IP Address',
|
header: 'IP Address',
|
||||||
|
|||||||
@ -8,8 +8,8 @@ export type DocumentsLogsPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
export default async function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentLogsPageView params={params} />;
|
return <DocumentLogsPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,8 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return <DocumentPageView params={params} />;
|
return <DocumentPageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
|
|
||||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
export default function DocumentSentPage() {
|
export default async function DocumentSentPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
|
|||||||
@ -37,6 +37,8 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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 { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||||
import { DuplicateDocumentDialog } from './duplicate-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 isOwner = row.User.id === session.user.id;
|
||||||
// const isRecipient = !!recipient;
|
// const isRecipient = !!recipient;
|
||||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
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 isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
@ -191,6 +193,20 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
<Trans>Share</Trans>
|
<Trans>Share</Trans>
|
||||||
</DropdownMenuLabel>
|
</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} />
|
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||||
|
|
||||||
<DocumentShareButton
|
<DocumentShareButton
|
||||||
|
|||||||
@ -87,7 +87,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
|
|
||||||
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(event.target.value);
|
setInputValue(event.target.value);
|
||||||
setIsDeleteEnabled(event.target.value === 'delete');
|
setIsDeleteEnabled(event.target.value === _(msg`delete`));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -117,10 +117,10 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
|
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
|
||||||
{isLoading ? 'Moving...' : 'Move'}
|
{isLoading ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export type AuthenticatedDashboardLayoutProps = {
|
|||||||
export default async function AuthenticatedDashboardLayout({
|
export default async function AuthenticatedDashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: AuthenticatedDashboardLayoutProps) {
|
}: AuthenticatedDashboardLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
||||||
|
|
||||||
|
|||||||
@ -44,11 +44,11 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const [interval, setInterval] = useState<Interval>('month');
|
const [interval, setInterval] = useState<Interval>('month');
|
||||||
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
|
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const onSubscribeClick = async (priceId: string) => {
|
const onSubscribeClick = async (priceId: string) => {
|
||||||
try {
|
try {
|
||||||
setIsFetchingCheckoutSession(true);
|
setCheckoutSessionPriceId(priceId);
|
||||||
|
|
||||||
const url = await createCheckout({ priceId });
|
const url = await createCheckout({ priceId });
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingCheckoutSession(false);
|
setCheckoutSessionPriceId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -122,7 +122,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
loading={isFetchingCheckoutSession}
|
disabled={checkoutSessionPriceId !== null}
|
||||||
|
loading={checkoutSessionPriceId === price.id}
|
||||||
onClick={() => void onSubscribeClick(price.id)}
|
onClick={() => void onSubscribeClick(price.id)}
|
||||||
>
|
>
|
||||||
<Trans>Subscribe</Trans>
|
<Trans>Subscribe</Trans>
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const { i18n } = setupI18nSSR();
|
const { i18n } = await setupI18nSSR();
|
||||||
|
|
||||||
let { user } = await getRequiredServerComponentSession();
|
let { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,8 @@ export type DashboardSettingsLayoutProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
export default async function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProfileSettingsPage() {
|
export default async function ProfileSettingsPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-p
|
|||||||
import { PublicProfilePageView } from './public-profile-page-view';
|
import { PublicProfilePageView } from './public-profile-page-view';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -14,8 +14,8 @@ export const metadata: Metadata = {
|
|||||||
title: 'Security activity',
|
title: 'Security activity',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsSecurityActivityPage() {
|
export default async function SettingsSecurityActivityPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SettingsManagePasskeysPage() {
|
export default async function SettingsManagePasskeysPage() {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-to
|
|||||||
import { ApiTokenForm } from '~/components/forms/token';
|
import { ApiTokenForm } from '~/components/forms/token';
|
||||||
|
|
||||||
export default async function ApiTokensPage() {
|
export default async function ApiTokensPage() {
|
||||||
const { i18n } = setupI18nSSR();
|
const { i18n } = await setupI18nSSR();
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { msg } from '@lingui/macro';
|
import { msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
@ -151,7 +152,10 @@ export const EditTemplateForm = ({
|
|||||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||||
globalActionAuth: data.globalActionAuth ?? null,
|
globalActionAuth: data.globalActionAuth ?? null,
|
||||||
},
|
},
|
||||||
meta: data.meta,
|
meta: {
|
||||||
|
...data.meta,
|
||||||
|
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
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,14 +1,15 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
import type { TemplatePageViewProps } from './template-page-view';
|
|
||||||
import { TemplatePageView } from './template-page-view';
|
import { TemplatePageView } from './template-page-view';
|
||||||
|
|
||||||
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
export type TemplatePageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function TemplatePage({ params }: TemplatePageProps) {
|
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return <TemplatePageView params={params} />;
|
return <TemplatePageView params={params} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,11 +10,13 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
|
|
||||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
||||||
|
|
||||||
export type TemplatePageViewProps = {
|
export type TemplateDirectLinkDialogWrapperProps = {
|
||||||
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
|
export const TemplateDirectLinkDialogWrapper = ({
|
||||||
|
template,
|
||||||
|
}: TemplateDirectLinkDialogWrapperProps) => {
|
||||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
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 Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { Trans } from '@lingui/macro';
|
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 { 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 { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
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 { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { DataTableActionDropdown } from '../data-table-action-dropdown';
|
||||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
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 { 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 = {
|
export type TemplatePageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -30,6 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
const templateId = Number(id);
|
const templateId = Number(id);
|
||||||
const templateRootPath = formatTemplatesPath(team?.url);
|
const templateRootPath = formatTemplatesPath(team?.url);
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
if (!templateId || Number.isNaN(templateId)) {
|
if (!templateId || Number.isNaN(templateId)) {
|
||||||
redirect(templateRootPath);
|
redirect(templateRootPath);
|
||||||
@ -37,29 +44,51 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const template = await getTemplateWithDetailsById({
|
const template = await getTemplateById({
|
||||||
id: templateId,
|
id: templateId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!template || !template.templateDocumentData) {
|
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
|
||||||
redirect(templateRootPath);
|
redirect(templateRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTemplateEnterprise = await isUserEnterprise({
|
const { templateDocumentData, Field, Recipient: recipients, templateMeta } = template;
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
// 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 (
|
const mockedDocumentMeta = templateMeta
|
||||||
<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">
|
typedSignatureEnabled: false,
|
||||||
<div>
|
...templateMeta,
|
||||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
documentId: 0,
|
||||||
<Trans>Templates</Trans>
|
}
|
||||||
</Link>
|
: 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}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
{template.title}
|
{template.title}
|
||||||
</h1>
|
</h1>
|
||||||
@ -77,17 +106,97 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
</div>
|
</div>
|
||||||
</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} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditTemplateForm
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
className="mt-6"
|
<Card
|
||||||
initialTemplate={template}
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
templateRootPath={templateRootPath}
|
gradient
|
||||||
isEnterprise={isTemplateEnterprise}
|
>
|
||||||
/>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Trans } from '@lingui/macro';
|
|||||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -23,7 +23,10 @@ import { MoveTemplateDialog } from './move-template-dialog';
|
|||||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: FindTemplateRow;
|
row: Template & {
|
||||||
|
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
};
|
};
|
||||||
@ -57,7 +60,7 @@ export const DataTableActionDropdown = ({
|
|||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||||
<Link href={`${templateRootPath}/${row.id}`}>
|
<Link href={`${templateRootPath}/${row.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export const TemplatesDataTable = ({
|
|||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<TemplateType type="PRIVATE" />
|
<TemplateType type={row.original.type} />
|
||||||
|
|
||||||
{row.original.directLink?.token && (
|
{row.original.directLink?.token && (
|
||||||
<TemplateDirectLinkBadge
|
<TemplateDirectLinkBadge
|
||||||
@ -145,6 +145,7 @@ export const TemplatesDataTable = ({
|
|||||||
<UseTemplateDialog
|
<UseTemplateDialog
|
||||||
templateId={row.original.id}
|
templateId={row.original.id}
|
||||||
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
templateSigningOrder={row.original.templateMeta?.signingOrder}
|
||||||
|
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
|
||||||
recipients={row.original.Recipient}
|
recipients={row.original.Recipient}
|
||||||
documentRootPath={documentRootPath}
|
documentRootPath={documentRootPath}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
setShowNewTemplateDialog(false);
|
setShowNewTemplateDialog(false);
|
||||||
|
|
||||||
router.push(`${templateRootPath}/${id}`);
|
router.push(`${templateRootPath}/${id}/edit`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
|
|||||||
@ -15,8 +15,8 @@ export const metadata: Metadata = {
|
|||||||
title: 'Templates',
|
title: 'Templates',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return <TemplatesPageView searchParams={searchParams} />;
|
return <TemplatesPageView searchParams={searchParams} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@ -15,7 +17,7 @@ import {
|
|||||||
} from '@documenso/lib/constants/template';
|
} from '@documenso/lib/constants/template';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -47,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
|||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z
|
const ZAddRecipientsForNewDocumentSchema = z
|
||||||
.object({
|
.object({
|
||||||
sendDocument: z.boolean(),
|
distributeDocument: z.boolean(),
|
||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@ -91,14 +93,18 @@ export type UseTemplateDialogProps = {
|
|||||||
templateId: number;
|
templateId: number;
|
||||||
templateSigningOrder?: DocumentSigningOrder | null;
|
templateSigningOrder?: DocumentSigningOrder | null;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
|
documentDistributionMethod?: DocumentDistributionMethod;
|
||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UseTemplateDialog({
|
export function UseTemplateDialog({
|
||||||
recipients,
|
recipients,
|
||||||
|
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
templateId,
|
templateId,
|
||||||
templateSigningOrder,
|
templateSigningOrder,
|
||||||
|
trigger,
|
||||||
}: UseTemplateDialogProps) {
|
}: UseTemplateDialogProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -112,7 +118,7 @@ export function UseTemplateDialog({
|
|||||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
sendDocument: false,
|
distributeDocument: false,
|
||||||
recipients: recipients
|
recipients: recipients
|
||||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||||
.map((recipient) => {
|
.map((recipient) => {
|
||||||
@ -143,7 +149,7 @@ export function UseTemplateDialog({
|
|||||||
templateId,
|
templateId,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
sendDocument: data.sendDocument,
|
distributeDocument: data.distributeDocument,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -152,7 +158,16 @@ export function UseTemplateDialog({
|
|||||||
duration: 5000,
|
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) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@ -186,10 +201,12 @@ export function UseTemplateDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="bg-background">
|
{trigger || (
|
||||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
<Button variant="outline" className="bg-background">
|
||||||
<Trans>Use Template</Trans>
|
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
</Button>
|
<Trans>Use Template</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -289,43 +306,76 @@ export function UseTemplateDialog({
|
|||||||
<div className="mt-4 flex flex-row items-center">
|
<div className="mt-4 flex flex-row items-center">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sendDocument"
|
name="distributeDocument"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="sendDocument"
|
id="distributeDocument"
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
checkClassName="dark:text-white text-primary"
|
checkClassName="dark:text-white text-primary"
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label
|
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
<label
|
||||||
htmlFor="sendDocument"
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
>
|
htmlFor="distributeDocument"
|
||||||
<Trans>Send document</Trans>
|
>
|
||||||
<Tooltip>
|
<Trans>Send document</Trans>
|
||||||
<TooltipTrigger type="button">
|
<Tooltip>
|
||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<TooltipTrigger type="button">
|
||||||
</TooltipTrigger>
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
{' '}
|
The document will be immediately sent to recipients if this is
|
||||||
The document will be immediately sent to recipients if this is
|
checked.
|
||||||
checked.
|
</Trans>
|
||||||
</Trans>
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<Trans>Otherwise, the document will be created as a draft.</Trans>
|
<Trans>
|
||||||
</p>
|
Otherwise, the document will be created as a draft.
|
||||||
</TooltipContent>
|
</Trans>
|
||||||
</Tooltip>
|
</p>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -341,10 +391,12 @@ export function UseTemplateDialog({
|
|||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<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 and send</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Create as draft</Trans>
|
<Trans>Create signing links</Trans>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
'use client';
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
@ -25,7 +25,12 @@ const dateFormat: DateTimeFormatOptions = {
|
|||||||
hourCycle: 'h12',
|
hourCycle: 'h12',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||||
|
*/
|
||||||
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const parser = new UAParser();
|
const parser = new UAParser();
|
||||||
|
|
||||||
const uppercaseFistLetter = (text: string) => {
|
const uppercaseFistLetter = (text: string) => {
|
||||||
@ -36,11 +41,11 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
|||||||
<Table overflowHidden>
|
<Table overflowHidden>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Time</TableHead>
|
<TableHead>{_(msg`Time`)}</TableHead>
|
||||||
<TableHead>User</TableHead>
|
<TableHead>{_(msg`User`)}</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>{_(msg`Action`)}</TableHead>
|
||||||
<TableHead>IP Address</TableHead>
|
<TableHead>{_(msg`IP Address`)}</TableHead>
|
||||||
<TableHead>Browser</TableHead>
|
<TableHead>{_(msg`Browser`)}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
@ -74,7 +79,7 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
|
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>{log.ipAddress}</TableCell>
|
<TableCell>{log.ipAddress}</TableCell>
|
||||||
|
|||||||
@ -2,13 +2,18 @@ import React from 'react';
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
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 { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
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) {
|
export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||||
|
const { i18n } = await setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { d } = searchParams;
|
const { d } = searchParams;
|
||||||
|
|
||||||
if (typeof d !== 'string' || !d) {
|
if (typeof d !== 'string' || !d) {
|
||||||
@ -44,6 +59,10 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
|||||||
return redirect('/');
|
return redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||||
|
|
||||||
|
await dynamicActivate(i18n, documentLanguage);
|
||||||
|
|
||||||
const { data: auditLogs } = await findDocumentAuditLogs({
|
const { data: auditLogs } = await findDocumentAuditLogs({
|
||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
userId: document.userId,
|
userId: document.userId,
|
||||||
@ -53,31 +72,35 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||||
<div className="flex items-center">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||||
<p>
|
<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>
|
<span className="mt-1 block break-words">{document.id}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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>
|
<span className="mt-1 block break-words">{document.title}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Owner</span>
|
<span className="font-medium">{_(msg`Owner`)}</span>
|
||||||
|
|
||||||
<span className="mt-1 block break-words">
|
<span className="mt-1 block break-words">
|
||||||
{document.User.name} ({document.User.email})
|
{document.User.name} ({document.User.email})
|
||||||
@ -85,7 +108,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Created At</span>
|
<span className="font-medium">{_(msg`Created At`)}</span>
|
||||||
|
|
||||||
<span className="mt-1 block">
|
<span className="mt-1 block">
|
||||||
{DateTime.fromJSDate(document.createdAt)
|
{DateTime.fromJSDate(document.createdAt)
|
||||||
@ -95,7 +118,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Last Updated</span>
|
<span className="font-medium">{_(msg`Last Updated`)}</span>
|
||||||
|
|
||||||
<span className="mt-1 block">
|
<span className="mt-1 block">
|
||||||
{DateTime.fromJSDate(document.updatedAt)
|
{DateTime.fromJSDate(document.updatedAt)
|
||||||
@ -105,7 +128,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Time Zone</span>
|
<span className="font-medium">{_(msg`Time Zone`)}</span>
|
||||||
|
|
||||||
<span className="mt-1 block break-words">
|
<span className="mt-1 block break-words">
|
||||||
{document.documentMeta?.timezone ?? 'N/A'}
|
{document.documentMeta?.timezone ?? 'N/A'}
|
||||||
@ -113,13 +136,13 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Recipients</p>
|
<p className="font-medium">{_(msg`Recipients`)}</p>
|
||||||
|
|
||||||
<ul className="mt-1 list-inside list-disc">
|
<ul className="mt-1 list-inside list-disc">
|
||||||
{document.Recipient.map((recipient) => (
|
{document.Recipient.map((recipient) => (
|
||||||
<li key={recipient.id}>
|
<li key={recipient.id}>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
[{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}]
|
[{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}]
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
{recipient.name} ({recipient.email})
|
{recipient.name} ({recipient.email})
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -2,20 +2,24 @@ import React from 'react';
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { UAParser } from 'ua-parser-js';
|
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 {
|
import {
|
||||||
RECIPIENT_ROLES_DESCRIPTION_ENG,
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
RECIPIENT_ROLE_SIGNING_REASONS_ENG,
|
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||||
} from '@documenso/lib/constants/recipient-roles';
|
} from '@documenso/lib/constants/recipient-roles';
|
||||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
|
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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import {
|
import {
|
||||||
@ -36,11 +40,21 @@ type SigningCertificateProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FRIENDLY_SIGNING_REASONS = {
|
const FRIENDLY_SIGNING_REASONS = {
|
||||||
['__OWNER__']: `I am the owner of this document`,
|
['__OWNER__']: msg`I am the owner of this document`,
|
||||||
...RECIPIENT_ROLE_SIGNING_REASONS_ENG,
|
...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) {
|
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
|
||||||
|
const { i18n } = await setupI18nSSR();
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { d } = searchParams;
|
const { d } = searchParams;
|
||||||
|
|
||||||
if (typeof d !== 'string' || !d) {
|
if (typeof d !== 'string' || !d) {
|
||||||
@ -63,6 +77,10 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
|||||||
return redirect('/');
|
return redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||||
|
|
||||||
|
await dynamicActivate(i18n, documentLanguage);
|
||||||
|
|
||||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||||
id: documentId,
|
id: documentId,
|
||||||
});
|
});
|
||||||
@ -98,17 +116,17 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
|||||||
});
|
});
|
||||||
|
|
||||||
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
||||||
.with('ACCOUNT', () => 'Account Re-Authentication')
|
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
||||||
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
|
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
|
||||||
.with('PASSKEY', () => 'Passkey Re-Authentication')
|
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
||||||
.with('EXPLICIT_NONE', () => 'Email')
|
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
||||||
.with(null, () => null)
|
.with(null, () => null)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
if (!authLevel) {
|
if (!authLevel) {
|
||||||
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
||||||
.with('ACCOUNT', () => 'Account Authentication')
|
.with('ACCOUNT', () => _(msg`Account Authentication`))
|
||||||
.with(null, () => 'Email')
|
.with(null, () => _(msg`Email`))
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +165,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
|||||||
return (
|
return (
|
||||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||||
<div className="flex items-center">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@ -155,9 +173,9 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
|||||||
<Table overflowHidden>
|
<Table overflowHidden>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Signer Events</TableHead>
|
<TableHead>{_(msg`Signer Events`)}</TableHead>
|
||||||
<TableHead>Signature</TableHead>
|
<TableHead>{_(msg`Signature`)}</TableHead>
|
||||||
<TableHead>Details</TableHead>
|
<TableHead>{_(msg`Details`)}</TableHead>
|
||||||
{/* <TableHead>Security</TableHead> */}
|
{/* <TableHead>Security</TableHead> */}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</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="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||||
<div className="break-all">{recipient.email}</div>
|
<div className="break-all">{recipient.email}</div>
|
||||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
<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>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
<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>
|
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -199,21 +217,21 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
<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">
|
<span className="block font-mono uppercase">
|
||||||
{signature.secondaryId}
|
{signature.secondaryId}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
<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">
|
<span className="inline-block">
|
||||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
|
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
<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">
|
<span className="inline-block">
|
||||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||||
</span>
|
</span>
|
||||||
@ -227,44 +245,46 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
|||||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<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">
|
<span className="inline-block">
|
||||||
{logs.EMAIL_SENT[0]
|
{logs.EMAIL_SENT[0]
|
||||||
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
|
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
|
||||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||||
: 'Unknown'}
|
: _(msg`Unknown`)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<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">
|
<span className="inline-block">
|
||||||
{logs.DOCUMENT_OPENED[0]
|
{logs.DOCUMENT_OPENED[0]
|
||||||
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
|
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
|
||||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||||
: 'Unknown'}
|
: _(msg`Unknown`)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<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">
|
<span className="inline-block">
|
||||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
||||||
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
|
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
|
||||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||||
: 'Unknown'}
|
: _(msg`Unknown`)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
<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">
|
<span className="inline-block">
|
||||||
{isOwner(recipient.email)
|
{_(
|
||||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
isOwner(recipient.email)
|
||||||
: FRIENDLY_SIGNING_REASONS[recipient.role]}
|
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||||
|
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -280,7 +300,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
|||||||
<div className="my-8 flex-row-reverse">
|
<div className="my-8 flex-row-reverse">
|
||||||
<div className="flex items-end justify-end gap-x-4">
|
<div className="flex items-end justify-end gap-x-4">
|
||||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||||
Signing certificate provided by:
|
{_(msg`Signing certificate provided by`)}:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Logo className="max-h-6 print:max-h-4" />
|
<Logo className="max-h-6 print:max-h-4" />
|
||||||
|
|||||||
@ -14,7 +14,7 @@ type PublicProfileLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) {
|
export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { user, session } = await getServerComponentSession();
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ const BADGE_DATA = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
|
export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { url: profileUrl } = params;
|
const { url: profileUrl } = params;
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@ -77,7 +77,7 @@ export const ConfigureDirectTemplateFormPartial = ({
|
|||||||
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) {
|
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Email cannot already exist in the template',
|
message: _(msg`Email cannot already exist in the template`),
|
||||||
path: ['email'],
|
path: ['email'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { msg } from '@lingui/macro';
|
import { msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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 { Field } from '@documenso/prisma/client';
|
||||||
import { type Recipient } from '@documenso/prisma/client';
|
import { type Recipient } from '@documenso/prisma/client';
|
||||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||||
@ -53,7 +53,9 @@ export const DirectTemplatePageView = ({
|
|||||||
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
||||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
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> = {
|
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
||||||
configure: {
|
configure: {
|
||||||
@ -62,9 +64,8 @@ export const DirectTemplatePageView = ({
|
|||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
sign: {
|
sign: {
|
||||||
// Todo: Translations
|
title: msg`${recipientActionVerb} document`,
|
||||||
title: msg`${recipientRoleDescription.actionVerb} document`,
|
description: msg`${recipientActionVerb} the document to complete the process.`,
|
||||||
description: msg`${recipientRoleDescription.actionVerb} the document to complete the process.`,
|
|
||||||
stepIndex: 2,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export type TemplatesDirectPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
|
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ type RecipientLayoutProps = {
|
|||||||
* Such as direct template access, or signing.
|
* Such as direct template access, or signing.
|
||||||
*/
|
*/
|
||||||
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
|
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { user, session } = await getServerComponentSession();
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,8 @@ export type SigningLayoutProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SigningLayout({ children }: SigningLayoutProps) {
|
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export type CompletedSigningPageProps = {
|
|||||||
export default async function CompletedSigningPage({
|
export default async function CompletedSigningPage({
|
||||||
params: { token },
|
params: { token },
|
||||||
}: CompletedSigningPageProps) {
|
}: CompletedSigningPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
@ -222,7 +222,7 @@ export default async function CompletedSigningPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoggedIn && (
|
{isLoggedIn && (
|
||||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600">
|
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-2">
|
||||||
<Trans>Go Back Home</Trans>
|
<Trans>Go Back Home</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -124,9 +124,9 @@ export const SigningForm = ({
|
|||||||
>
|
>
|
||||||
<div className={cn('flex flex-1 flex-col')}>
|
<div className={cn('flex flex-1 flex-col')}>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
{recipient.role === RecipientRole.VIEWER && 'View Document'}
|
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
||||||
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
|
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
||||||
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
|
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{recipient.role === RecipientRole.VIEWER ? (
|
{recipient.role === RecipientRole.VIEWER ? (
|
||||||
@ -166,7 +166,7 @@ export const SigningForm = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Please review the document before signing.
|
<Trans>Please review the document before signing.</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<hr className="border-border mb-8 mt-4" />
|
||||||
@ -174,7 +174,9 @@ export const SigningForm = ({
|
|||||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
<div className="flex flex-1 flex-col gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="full-name">Full Name</Label>
|
<Label htmlFor="full-name">
|
||||||
|
<Trans>Full Name</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@ -186,7 +188,9 @@ export const SigningForm = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">Signature</Label>
|
<Label htmlFor="Signature">
|
||||||
|
<Trans>Signature</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@ -213,7 +217,7 @@ export const SigningForm = ({
|
|||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<SignDialog
|
<SignDialog
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useTransition } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
@ -37,6 +39,7 @@ export const InitialsField = ({
|
|||||||
}: InitialsFieldProps) => {
|
}: InitialsFieldProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { fullName } = useRequiredSigningContext();
|
const { fullName } = useRequiredSigningContext();
|
||||||
const initials = extractInitials(fullName);
|
const initials = extractInitials(fullName);
|
||||||
@ -83,8 +86,8 @@ export const InitialsField = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while signing the document.',
|
description: _(msg`An error occurred while signing the document.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -109,8 +112,8 @@ export const InitialsField = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while removing the signature.',
|
description: _(msg`An error occurred while removing the field.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -126,7 +129,7 @@ export const InitialsField = ({
|
|||||||
|
|
||||||
{!field.inserted && (
|
{!field.inserted && (
|
||||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||||
Initials
|
<Trans>Initials</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export type SigningLayoutProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { user, session } = await getServerComponentSession();
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export type SigningPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function SigningPage({ params: { token } }: SigningPageProps) {
|
export default async function SigningPage({ params: { token } }: SigningPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return notFound();
|
return notFound();
|
||||||
@ -43,12 +43,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
|
|
||||||
|
|
||||||
if (!isRecipientsTurn) {
|
|
||||||
return redirect(`/sign/${token}/waiting`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
@ -69,6 +63,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
|
||||||
|
|
||||||
|
if (!isRecipientsTurn) {
|
||||||
|
return redirect(`/sign/${token}/waiting`);
|
||||||
|
}
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
recipientAuth: recipient.authOptions,
|
recipientAuth: recipient.authOptions,
|
||||||
|
|||||||
@ -21,7 +21,7 @@ type WaitingForTurnToSignPageProps = {
|
|||||||
export default async function WaitingForTurnToSignPage({
|
export default async function WaitingForTurnToSignPage({
|
||||||
params: { token },
|
params: { token },
|
||||||
}: WaitingForTurnToSignPageProps) {
|
}: WaitingForTurnToSignPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return notFound();
|
return notFound();
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
|
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { teamUrl } = params;
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export type TeamDocumentsLogsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
|
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { teamUrl } = params;
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { teamUrl } = params;
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export default async function TeamsDocumentPage({
|
|||||||
params,
|
params,
|
||||||
searchParams = {},
|
searchParams = {},
|
||||||
}: TeamsDocumentPageProps) {
|
}: TeamsDocumentPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { teamUrl } = params;
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export default async function AuthenticatedTeamsLayout({
|
|||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
}: AuthenticatedTeamsLayoutProps) {
|
}: AuthenticatedTeamsLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { session, user } = await getServerComponentSession();
|
const { session, user } = await getServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export type TeamsSettingsBillingPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
|
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export default async function TeamsSettingsLayout({
|
|||||||
children,
|
children,
|
||||||
params: { teamUrl },
|
params: { teamUrl },
|
||||||
}: TeamSettingsLayoutProps) {
|
}: TeamSettingsLayoutProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const session = await getRequiredServerComponentSession();
|
const session = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export type TeamsSettingsMembersPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
|
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { teamUrl } = params;
|
const { teamUrl } = params;
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export type TeamsSettingsPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
||||||
setupI18nSSR();
|
await setupI18nSSR();
|
||||||
|
|
||||||
const { teamUrl } = params;
|
const { teamUrl } = params;
|
||||||
|
|
||||||
@ -52,7 +52,13 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
|
|||||||
|
|
||||||
<AvatarImageForm className="mb-8" team={team} user={session.user} />
|
<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">
|
<section className="mt-6 space-y-6">
|
||||||
{(team.teamEmail || team.emailVerification) && (
|
{(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,235 @@
|
|||||||
|
'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
|
||||||
|
? _(msg`"${placeholderEmail}" on behalf of "${team.name}" has invited you to sign "example
|
||||||
|
document".`)
|
||||||
|
: _(msg`"${team.name}" has invited you to sign "example document".`)}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user