diff --git a/.env.example b/.env.example index b2dfb0805..559684160 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,8 @@ NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS= NEXT_PRIVATE_SMTP_FROM_NAME="Documenso" # REQUIRED: Defines the email address to use as the from address. NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com" +# OPTIONAL: Defines the service for nodemailer +NEXT_PRIVATE_SMTP_SERVICE= # OPTIONAL: The API key to use for Resend.com NEXT_PRIVATE_RESEND_API_KEY= # OPTIONAL: The API key to use for MailChannels. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f69ddb57b..5515b37a6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -89,22 +89,35 @@ jobs: APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" GIT_SHA="$(git rev-parse HEAD)" - docker manifest create \ - documenso/documenso:latest \ - --amend documenso/documenso-amd64:latest \ - --amend documenso/documenso-arm64:latest \ + # Check if the version is stable (no rc or beta in the version) + if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + docker manifest create \ + documenso/documenso:latest \ + --amend documenso/documenso-amd64:latest \ + --amend documenso/documenso-arm64:latest + + docker manifest push documenso/documenso:latest + fi + + if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + docker manifest create \ + documenso/documenso:rc \ + --amend documenso/documenso-amd64:rc \ + --amend documenso/documenso-arm64:rc + + docker manifest push documenso/documenso:rc + fi docker manifest create \ documenso/documenso:$GIT_SHA \ --amend documenso/documenso-amd64:$GIT_SHA \ - --amend documenso/documenso-arm64:$GIT_SHA \ + --amend documenso/documenso-arm64:$GIT_SHA docker manifest create \ documenso/documenso:$APP_VERSION \ --amend documenso/documenso-amd64:$APP_VERSION \ - --amend documenso/documenso-arm64:$APP_VERSION \ + --amend documenso/documenso-arm64:$APP_VERSION - docker manifest push documenso/documenso:latest docker manifest push documenso/documenso:$GIT_SHA docker manifest push documenso/documenso:$APP_VERSION @@ -113,21 +126,34 @@ jobs: APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" GIT_SHA="$(git rev-parse HEAD)" - docker manifest create \ - ghcr.io/documenso/documenso:latest \ - --amend ghcr.io/documenso/documenso-amd64:latest \ - --amend ghcr.io/documenso/documenso-arm64:latest \ + # Check if the version is stable (no rc or beta in the version) + if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + docker manifest create \ + ghcr.io/documenso/documenso:latest \ + --amend ghcr.io/documenso/documenso-amd64:latest \ + --amend ghcr.io/documenso/documenso-arm64:latest + + docker manifest push ghcr.io/documenso/documenso:latest + fi + + if [[ "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + docker manifest create \ + ghcr.io/documenso/documenso:rc \ + --amend ghcr.io/documenso/documenso-amd64:rc \ + --amend ghcr.io/documenso/documenso-arm64:rc + + docker manifest push ghcr.io/documenso/documenso:rc + fi docker manifest create \ ghcr.io/documenso/documenso:$GIT_SHA \ --amend ghcr.io/documenso/documenso-amd64:$GIT_SHA \ - --amend ghcr.io/documenso/documenso-arm64:$GIT_SHA \ + --amend ghcr.io/documenso/documenso-arm64:$GIT_SHA docker manifest create \ ghcr.io/documenso/documenso:$APP_VERSION \ --amend ghcr.io/documenso/documenso-amd64:$APP_VERSION \ - --amend ghcr.io/documenso/documenso-arm64:$APP_VERSION \ + --amend ghcr.io/documenso/documenso-arm64:$APP_VERSION - docker manifest push ghcr.io/documenso/documenso:latest docker manifest push ghcr.io/documenso/documenso:$GIT_SHA docker manifest push ghcr.io/documenso/documenso:$APP_VERSION diff --git a/.github/workflows/translations-upload.yml b/.github/workflows/translations-upload.yml index 8a8564c29..cb69d6338 100644 --- a/.github/workflows/translations-upload.yml +++ b/.github/workflows/translations-upload.yml @@ -21,7 +21,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.ref }} token: ${{ secrets.GH_PAT }} - uses: ./.github/actions/node-install diff --git a/apps/documentation/pages/developers/local-development/signing-certificate.mdx b/apps/documentation/pages/developers/local-development/signing-certificate.mdx index c06fe9440..b8c5b4812 100644 --- a/apps/documentation/pages/developers/local-development/signing-certificate.mdx +++ b/apps/documentation/pages/developers/local-development/signing-certificate.mdx @@ -11,6 +11,10 @@ Digitally signing documents requires a signing certificate in `.p12` format. You Follow the steps below to create a free, self-signed certificate for local development. + + These steps should be run on a UNIX based system, otherwise you may run into an error. + + ### Generate Private Key @@ -38,11 +42,17 @@ You will be prompted to enter some information, such as the certificate's Common Combine the private key and the self-signed certificate to create a `.p12` certificate. Use the following command: ```bash -openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt +openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -legacy ``` - 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 +``` @@ -54,8 +64,8 @@ Note that for local development, the password can be left empty. ### Add Certificate to the Project -Finally, add the certificate to the project. Place the `certificate.p12` file in the `/apps/web/resources` directory. If the directory doesn't exist, create it. +Use the `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` environment variable to point at the certificate you created. -The final file path should be `/apps/web/resources/certificate.p12`. +Details about environment variables associated with certificates can be found [here](/developers/self-hosting/signing-certificate#configure-documenso-to-use-the-certificate). diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index a316b02b1..0d1583859 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -133,7 +133,7 @@ volumes: After updating the volume binding, save the `compose.yml` file and run the following command to start the containers: ```bash -docker-compose --env-file ./.env -d up +docker-compose --env-file ./.env up -d ``` The command will start the PostgreSQL database and the Documenso application containers. diff --git a/apps/documentation/pages/users/_meta.json b/apps/documentation/pages/users/_meta.json index 0d25e4a84..53733ea63 100644 --- a/apps/documentation/pages/users/_meta.json +++ b/apps/documentation/pages/users/_meta.json @@ -11,6 +11,7 @@ "templates": "Templates", "direct-links": "Direct Signing Links", "document-visibility": "Document Visibility", + "teams": "Teams", "-- Legal Overview": { "type": "separator", "title": "Legal Overview" diff --git a/apps/documentation/pages/users/document-visibility.mdx b/apps/documentation/pages/users/document-visibility.mdx deleted file mode 100644 index 8120f80bc..000000000 --- a/apps/documentation/pages/users/document-visibility.mdx +++ /dev/null @@ -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. - -![A screenshot of the Documenso's document editor page where you can update the document visibility](/document-visibility-settings.webp) - -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. diff --git a/apps/documentation/pages/users/teams/_meta.json b/apps/documentation/pages/users/teams/_meta.json new file mode 100644 index 000000000..b9548a39b --- /dev/null +++ b/apps/documentation/pages/users/teams/_meta.json @@ -0,0 +1,5 @@ +{ + "general-settings": "General Settings", + "document-visibility": "Document Visibility", + "sender-details": "Email Sender Details" +} diff --git a/apps/documentation/pages/users/teams/document-visibility.mdx b/apps/documentation/pages/users/teams/document-visibility.mdx new file mode 100644 index 000000000..8d2f82266 --- /dev/null +++ b/apps/documentation/pages/users/teams/document-visibility.mdx @@ -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. + +![A screenshot of the document visibility selector from the team's general settings page](/teams/team-general-settings-document-visibility-select.webp) + +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. + + + 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. + + +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. + +![A screenshot of the Documenso's document editor page where you can update the document visibility](/teams/document-visibility-settings.webp) + + + 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. + + +## 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. diff --git a/apps/documentation/pages/users/teams/general-settings.mdx b/apps/documentation/pages/users/teams/general-settings.mdx new file mode 100644 index 000000000..e10d379b0 --- /dev/null +++ b/apps/documentation/pages/users/teams/general-settings.mdx @@ -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. + +![A screenshot of team's General settings page](/teams/team-general-settings.webp) + +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). diff --git a/apps/documentation/pages/users/teams/sender-details.mdx b/apps/documentation/pages/users/teams/sender-details.mdx new file mode 100644 index 000000000..196cd22e7 --- /dev/null +++ b/apps/documentation/pages/users/teams/sender-details.mdx @@ -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" diff --git a/apps/documentation/public/document-visibility-settings.webp b/apps/documentation/public/teams/document-visibility-settings.webp similarity index 100% rename from apps/documentation/public/document-visibility-settings.webp rename to apps/documentation/public/teams/document-visibility-settings.webp diff --git a/apps/documentation/public/teams/team-general-settings-document-visibility-select.webp b/apps/documentation/public/teams/team-general-settings-document-visibility-select.webp new file mode 100644 index 000000000..ef312eeeb Binary files /dev/null and b/apps/documentation/public/teams/team-general-settings-document-visibility-select.webp differ diff --git a/apps/documentation/public/teams/team-general-settings.webp b/apps/documentation/public/teams/team-general-settings.webp new file mode 100644 index 000000000..3b5607e6a Binary files /dev/null and b/apps/documentation/public/teams/team-general-settings.webp differ diff --git a/apps/marketing/content/blog/project-babel.mdx b/apps/marketing/content/blog/project-babel.mdx new file mode 100644 index 000000000..f63ad72df --- /dev/null +++ b/apps/marketing/content/blog/project-babel.mdx @@ -0,0 +1,64 @@ +--- +title: Project Babel +description: We are announcing Project Babel - an initiative to support all languages of the world on Documenso. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-11-08 +tags: + - Languages + - Community + - Open Source +--- + +
+ + +
The tower of Babel to add some Gravitas to this project.
+
+ +> TLDR; We are opening up translations to the community. Read this to add a language: https://documen.so/babel-fish + +## Announcing Project Babel: Powering Documenso with Global Language Support + +At Documenso, we believe that open source is more than just a software philosophy—it’s a way to build solutions that are open to all. Now, we’re happy to take that mission further with Project Babel, a community-driven initiative designed to bring worldwide language support to the Documenso platform. This project aims to enable Documenso to support as many languages as possible. + +## Why Language Support Matters + +We already have customers from 36 different countries and are seeing traffic from even more. When it comes to critical tools like signature platforms, having a user interface in your native language can make all the difference. No matter who and where you are, your team deserves tools that are fully accessible and intuitive. That’s why we’re making it our goal to support every language, and we need your help to make it happen! We’re building Documenso as a truly global public commodity. + +## The Vision Behind Project Babel + +The goal of Project Babel is simple but bold: We want to out-ship and out-customize every other document signing tool worldwide. How? By leveraging the collective power of our global community. + +Unlike closed-source software, where localization means waiting for updates from the core team, Project Babel lets anyone contribute a new language, improve an existing translation, or customize the experience to meet local cultural nuances. This flexibility isn’t just a bonus—it’s the baseline for truly global products. + +Through Project Babel, you can help make Documenso the most inclusive e-signature tool. Whether by adding a language you speak or fine-tuning existing translations, you’re shaping a platform that works for everyone, everywhere. + +## How You Can Contribute + +We’ve created a simple GitHub-based contribution flow to get started. We’ll improve the flow and user experience as the project progresses. As always, your contributions are highly valued. + + Check out the contribution guide here: [https://documen.so/babel-fish](https://documen.so/babel-fish) + +## Open Source Makes It Possible + +Closed-source solutions can’t keep up with the speed or depth of customization that open source offers. While other companies might take months or years to localize their products, Documenso can adapt and grow in real-time, thanks to contributions from our community. Whether it’s a small regional dialect or a widely spoken language, Project Babel ensures that Documenso evolves to meet the needs of people everywhere. + +> More importantly, this initiative empowers users. It allows you to control your software experience, ensuring it reflects your culture, language, and unique needs. + +Project Babel is more than a localization effort—it’s the first step toward democratizing access to highly customized software for everyone, no matter where they are or what language they speak. We’re incredibly excited about this initiative, but it can only succeed with your participation. We invite you to join us in making Documenso the most linguistically inclusive platform out there. + +Ready to get started? Check out the full tutorial and become part of the Babel community today! Let’s build open signing for the world: https://documen.so/babel-fish + +If you have any questions or comments, reach out on [Twitter / X](https://twitter.com/eltimuro) (DMs open) or [Discord](https://documen.so/discord). + +Thinking about switching to a modern signing platform? Reach out anytime: [https://documen.so/sales](https://documen.so/sales) + +Best from Hamburg\ +Timur diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 907f74698..ce7cb7365 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -1,6 +1,6 @@ { "name": "@documenso/marketing", - "version": "1.7.2-rc.1", + "version": "1.8.0-rc.2", "private": true, "license": "AGPL-3.0", "scripts": { diff --git a/apps/marketing/public/blog/babel.png b/apps/marketing/public/blog/babel.png new file mode 100644 index 000000000..926cffbd1 Binary files /dev/null and b/apps/marketing/public/blog/babel.png differ diff --git a/apps/marketing/src/app/(marketing)/[content]/content.tsx b/apps/marketing/src/app/(marketing)/[content]/content.tsx new file mode 100644 index 000000000..d263d8c35 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/[content]/content.tsx @@ -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 }) => ( + {props.alt + ), +}; + +export type ContentPageContentProps = { + document: DocumentTypes; +}; + +export const ContentPageContent = ({ document }: ContentPageContentProps) => { + const MDXContent = useMDXComponent(document.body.code); + + return ; +}; diff --git a/apps/marketing/src/app/(marketing)/[content]/page.tsx b/apps/marketing/src/app/(marketing)/[content]/page.tsx index 2e8327944..9bcc2b92d 100644 --- a/apps/marketing/src/app/(marketing)/[content]/page.tsx +++ b/apps/marketing/src/app/(marketing)/[content]/page.tsx @@ -1,12 +1,11 @@ -import Image from 'next/image'; import { notFound } from 'next/navigation'; import { allDocuments } from 'contentlayer/generated'; -import type { MDXComponents } from 'mdx/types'; -import { useMDXComponent } from 'next-contentlayer/hooks'; import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { ContentPageContent } from './content'; + export const dynamic = 'force-dynamic'; export const generateMetadata = ({ params }: { params: { content: string } }) => { @@ -19,19 +18,13 @@ export const generateMetadata = ({ params }: { params: { content: string } }) => return { title: document.title }; }; -const mdxComponents: MDXComponents = { - MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => ( - {props.alt - ), -}; - /** * 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. */ -export default function ContentPage({ params }: { params: { content: string } }) { - setupI18nSSR(); +export default async function ContentPage({ params }: { params: { content: string } }) { + await setupI18nSSR(); const post = allDocuments.find((post) => post._raw.flattenedPath === params.content); @@ -39,11 +32,9 @@ export default function ContentPage({ params }: { params: { content: string } }) notFound(); } - const MDXContent = useMDXComponent(post.body.code); - return (
- +
); } diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/content.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/content.tsx new file mode 100644 index 000000000..ebcd5f8b9 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/blog/[post]/content.tsx @@ -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 }) => ( + {props.alt + ), +}; + +export type BlogPostContentProps = { + post: BlogPost; +}; + +export const BlogPostContent = ({ post }: BlogPostContentProps) => { + const MdxContent = useMDXComponent(post.body.code); + + return ; +}; diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx index 4f99126f3..03c5bcb81 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx @@ -1,16 +1,15 @@ -import Image from 'next/image'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { allBlogPosts } from 'contentlayer/generated'; import { ChevronLeft } from 'lucide-react'; -import type { MDXComponents } from 'mdx/types'; -import { useMDXComponent } from 'next-contentlayer/hooks'; import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; import { CallToAction } from '~/components/(marketing)/call-to-action'; +import { BlogPostContent } from './content'; + export const dynamic = 'force-dynamic'; export const generateMetadata = ({ params }: { params: { post: string } }) => { @@ -42,14 +41,8 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => { }; }; -const mdxComponents: MDXComponents = { - MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => ( - {props.alt - ), -}; - -export default function BlogPostPage({ params }: { params: { post: string } }) { - setupI18nSSR(); +export default async function BlogPostPage({ params }: { params: { post: string } }) { + await setupI18nSSR(); const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); @@ -57,8 +50,6 @@ export default function BlogPostPage({ params }: { params: { post: string } }) { notFound(); } - const MDXContent = useMDXComponent(post.body.code); - return (
@@ -87,7 +78,7 @@ export default function BlogPostPage({ params }: { params: { post: string } }) {
- + {post.tags.length > 0 && (
    diff --git a/apps/marketing/src/app/(marketing)/blog/page.tsx b/apps/marketing/src/app/(marketing)/blog/page.tsx index 4974a2399..a762f4c2a 100644 --- a/apps/marketing/src/app/(marketing)/blog/page.tsx +++ b/apps/marketing/src/app/(marketing)/blog/page.tsx @@ -9,8 +9,8 @@ export const metadata: Metadata = { title: 'Blog', }; -export default function BlogPage() { - const { i18n } = setupI18nSSR(); +export default async function BlogPage() { + const { i18n } = await setupI18nSSR(); const blogPosts = allBlogPosts.sort((a, b) => { const dateA = new Date(a.date); diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index 676a352bb..367afcd5a 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -131,7 +131,7 @@ const fetchEarlyAdopters = async () => { }; export default async function OpenPage() { - setupI18nSSR(); + await setupI18nSSR(); const { _ } = useLingui(); diff --git a/apps/marketing/src/app/(marketing)/page.tsx b/apps/marketing/src/app/(marketing)/page.tsx index 2f5dd14c8..816b1aaa1 100644 --- a/apps/marketing/src/app/(marketing)/page.tsx +++ b/apps/marketing/src/app/(marketing)/page.tsx @@ -26,7 +26,7 @@ const fontCaveat = Caveat({ }); export default async function IndexPage() { - setupI18nSSR(); + await setupI18nSSR(); const starCount = await fetch('https://api.github.com/repos/documenso/documenso', { headers: { diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index 61a802cb8..4c89490c0 100644 --- a/apps/marketing/src/app/(marketing)/pricing/page.tsx +++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx @@ -30,8 +30,8 @@ export type PricingPageProps = { }; }; -export default function PricingPage() { - setupI18nSSR(); +export default async function PricingPage() { + await setupI18nSSR(); return (
    diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 35e1ccb7f..3062049b4 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -163,6 +163,7 @@ export const SinglePlayerClient = () => { expired: null, signedAt: null, readStatus: 'OPENED', + rejectionReason: null, documentDeletedAt: null, signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', diff --git a/apps/marketing/src/app/(marketing)/singleplayer/page.tsx b/apps/marketing/src/app/(marketing)/singleplayer/page.tsx index 1416067e4..16da4e6b9 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/page.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/page.tsx @@ -14,8 +14,8 @@ export const dynamic = 'force-dynamic'; // !: This entire file is a hack to get around failed prerendering of // !: the Single Player Mode page. This regression was introduced during // !: the upgrade of Next.js to v13.5.x. -export default function SingleplayerPage() { - setupI18nSSR(); +export default async function SingleplayerPage() { + await setupI18nSSR(); return ; } diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx index 544b5ceb4..ea879d89d 100644 --- a/apps/marketing/src/app/layout.tsx +++ b/apps/marketing/src/app/layout.tsx @@ -56,7 +56,7 @@ export function generateMetadata() { export default async function RootLayout({ children }: { children: React.ReactNode }) { const flags = await getAllAnonymousFlags(); - const { lang, locales, i18n } = setupI18nSSR(); + const { lang, locales, i18n } = await setupI18nSSR(); return ( diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx index c489c34a1..964267872 100644 --- a/apps/web/src/app/(dashboard)/admin/layout.tsx +++ b/apps/web/src/app/(dashboard)/admin/layout.tsx @@ -13,7 +13,7 @@ export type AdminSectionLayoutProps = { }; export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) { - setupI18nSSR(); + await setupI18nSSR(); const { user } = await getRequiredServerComponentSession(); diff --git a/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx b/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx index a9d37caff..c78eb87ec 100644 --- a/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/site-settings/page.tsx @@ -12,7 +12,7 @@ import { BannerForm } from './banner-form'; // import { BannerForm } from './banner-form'; export default async function AdminBannerPage() { - setupI18nSSR(); + await setupI18nSSR(); const { _ } = useLingui(); diff --git a/apps/web/src/app/(dashboard)/admin/stats/page.tsx b/apps/web/src/app/(dashboard)/admin/stats/page.tsx index 33be711e1..9ffbfb5dc 100644 --- a/apps/web/src/app/(dashboard)/admin/stats/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/stats/page.tsx @@ -30,7 +30,7 @@ import { SignerConversionChart } from './signer-conversion-chart'; import { UserWithDocumentChart } from './user-with-document'; export default async function AdminStatsPage() { - setupI18nSSR(); + await setupI18nSSR(); const { _ } = useLingui(); diff --git a/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx b/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx index f31e9d13e..7940b6fb5 100644 --- a/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx @@ -14,7 +14,7 @@ import { } from '@documenso/ui/primitives/table'; export default async function Subscriptions() { - setupI18nSSR(); + await setupI18nSSR(); const subscriptions = await findSubscriptions(); diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 807fd45c9..55803f6f9 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -16,7 +16,7 @@ type AdminManageUsersProps = { }; export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) { - setupI18nSSR(); + await setupI18nSSR(); const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 10; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index ee86c17c5..a5852b40e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -33,6 +33,8 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; + import { ResendDocumentActionItem } from '../_action-items/resend-document'; import { DeleteDocumentDialog } from '../delete-document-dialog'; import { DuplicateDocumentDialog } from '../duplicate-document-dialog'; @@ -62,6 +64,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro const isOwner = document.User.id === session.user.id; const isDraft = document.status === DocumentStatus.DRAFT; + const isPending = document.status === DocumentStatus.PENDING; const isDeleted = document.deletedAt !== null; const isComplete = document.status === DocumentStatus.COMPLETED; const isCurrentTeamDocument = team && document.team?.url === team.url; @@ -145,6 +148,21 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro Share + {canManageDocument && ( + e.preventDefault()} + > + + Signing Links + + } + /> + )} +
    )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => ( +
    +
    + )) .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
    - {/* Todo: Translations. */}

    - - {formatDocumentAuditLogAction(auditLog, userId).prefix} - {' '} - {formatDocumentAuditLogAction(auditLog, userId).description} + {formatDocumentAuditLogAction(_, auditLog, userId).description}

diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index c827899a9..d4b137aeb 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -26,6 +26,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; import { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -73,7 +74,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email); let canAccessDocument = true; - if (team && !isRecipient) { + if (team && !isRecipient && document?.userId !== user.id) { canAccessDocument = match([documentVisibility, currentTeamMemberRole]) .with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true) .with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true) @@ -134,6 +135,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) return (
+ {document.status === DocumentStatus.PENDING && ( + + )} + Documents diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 571ca535f..3030794ba 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -7,10 +7,12 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; +import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; +import { DocumentDistributionMethod, DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -176,8 +178,8 @@ export const EditDocumentForm = ({ stepIndex: 3, }, subject: { - title: msg`Add Subject`, - description: msg`Add the subject and message you wish to send to signers.`, + title: msg`Distribute Document`, + description: msg`Choose how the document will reach recipients`, stepIndex: 4, }, }; @@ -201,7 +203,7 @@ export const EditDocumentForm = ({ const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { try { - const { timezone, dateFormat, redirectUrl } = data.meta; + const { timezone, dateFormat, redirectUrl, language } = data.meta; await setSettingsForDocument({ documentId: document.id, @@ -217,6 +219,7 @@ export const EditDocumentForm = ({ timezone, dateFormat, redirectUrl, + language: isValidLanguageCode(language) ? language : undefined, }, }); @@ -305,7 +308,7 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message } = data.meta; + const { subject, message, distributionMethod, emailSettings } = data.meta; try { await sendDocument({ @@ -314,16 +317,31 @@ export const EditDocumentForm = ({ meta: { subject, message, + distributionMethod, + emailSettings, }, }); - toast({ - title: _(msg`Document sent`), - description: _(msg`Your document has been sent successfully.`), - duration: 5000, - }); + if (distributionMethod === DocumentDistributionMethod.EMAIL) { + toast({ + title: _(msg`Document sent`), + description: _(msg`Your document has been sent successfully.`), + duration: 5000, + }); - router.push(documentRootPath); + router.push(documentRootPath); + return; + } + + if (document.status === DocumentStatus.DRAFT) { + toast({ + title: _(msg`Links Generated`), + description: _(msg`Signing links have been generated for this document.`), + duration: 5000, + }); + } else { + router.push(`${documentRootPath}/${document.id}`); + } } catch (err) { console.error(err); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index 42141e451..23357074a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -55,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie const isRecipient = document?.Recipient.find((recipient) => recipient.email === user.email); let canAccessDocument = true; - if (!isRecipient) { + if (!isRecipient && document?.userId !== user.id) { canAccessDocument = match([documentVisibility, currentTeamMemberRole]) .with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true) .with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx index b91bf4e9a..a20cc8469 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx @@ -8,8 +8,8 @@ export type DocumentPageProps = { }; }; -export default function DocumentEditPage({ params }: DocumentPageProps) { - setupI18nSSR(); +export default async function DocumentEditPage({ params }: DocumentPageProps) { + await setupI18nSSR(); return ; } diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx index 65d508482..b6165436b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx @@ -6,8 +6,8 @@ import { ChevronLeft, Loader } from 'lucide-react'; import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; -export default function Loading() { - setupI18nSSR(); +export default async function Loading() { + await setupI18nSSR(); return (
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx index 953dbec35..3058518a7 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-data-table.tsx @@ -58,10 +58,6 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps }); }; - const uppercaseFistLetter = (text: string) => { - return text.charAt(0).toUpperCase() + text.slice(1); - }; - const results = data ?? { data: [], perPage: 10, @@ -103,9 +99,7 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps { header: _(msg`Action`), accessorKey: 'type', - cell: ({ row }) => ( - {uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)} - ), + cell: ({ row }) => {formatDocumentAuditLogAction(_, row.original).description}, }, { header: 'IP Address', diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx index c6c3c9ad3..4bd852248 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx @@ -139,6 +139,7 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie className="mr-2" documentId={document.id} documentStatus={document.status} + teamId={team?.id} /> diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx index aed252670..7cc262d3d 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -14,12 +14,14 @@ export type DownloadCertificateButtonProps = { className?: string; documentId: number; documentStatus: DocumentStatus; + teamId?: number; }; export const DownloadCertificateButton = ({ className, documentId, documentStatus, + teamId, }: DownloadCertificateButtonProps) => { const { toast } = useToast(); const { _ } = useLingui(); @@ -29,7 +31,7 @@ export const DownloadCertificateButton = ({ const onDownloadCertificatesClick = async () => { try { - const { url } = await downloadCertificate({ documentId }); + const { url } = await downloadCertificate({ documentId, teamId }); const iframe = Object.assign(document.createElement('iframe'), { src: url, diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx index 7c91ac8fc..c7c489ef1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx @@ -8,8 +8,8 @@ export type DocumentsLogsPageProps = { }; }; -export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) { - setupI18nSSR(); +export default async function DocumentsLogsPage({ params }: DocumentsLogsPageProps) { + await setupI18nSSR(); return ; } diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 101b707fb..4e570effe 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -8,8 +8,8 @@ export type DocumentPageProps = { }; }; -export default function DocumentPage({ params }: DocumentPageProps) { - setupI18nSSR(); +export default async function DocumentPage({ params }: DocumentPageProps) { + await setupI18nSSR(); return ; } diff --git a/apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx index b6938f154..9d6e20535 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx @@ -5,8 +5,8 @@ import { ChevronLeft } from 'lucide-react'; import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; -export default function DocumentSentPage() { - setupI18nSSR(); +export default async function DocumentSentPage() { + await setupI18nSSR(); return (
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index a4595f2fd..4e76f4ef0 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -37,6 +37,8 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog'; + import { ResendDocumentActionItem } from './_action-items/resend-document'; import { DeleteDocumentDialog } from './delete-document-dialog'; import { DuplicateDocumentDialog } from './duplicate-document-dialog'; @@ -69,7 +71,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr const isOwner = row.User.id === session.user.id; // const isRecipient = !!recipient; const isDraft = row.status === DocumentStatus.DRAFT; - // const isPending = row.status === DocumentStatus.PENDING; + const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isCurrentTeamDocument = team && row.team?.url === team.url; @@ -191,6 +193,20 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Share + {canManageDocument && ( + e.preventDefault()}> +
+ + Signing Links +
+ + } + /> + )} + , + cell: ({ row }) => , size: 140, }, { diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index fdf3f4fa5..912de8f11 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -87,7 +87,7 @@ export const DeleteDocumentDialog = ({ const onInputChange = (event: React.ChangeEvent) => { setInputValue(event.target.value); - setIsDeleteEnabled(event.target.value === 'delete'); + setIsDeleteEnabled(event.target.value === _(msg`delete`)); }; return ( diff --git a/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx index bf84d3e81..8de33f8c2 100644 --- a/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx @@ -117,10 +117,10 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index 241bee6f0..41cbf3df9 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -16,7 +16,7 @@ export const metadata: Metadata = { }; export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { - setupI18nSSR(); + await setupI18nSSR(); const { user } = await getRequiredServerComponentSession(); diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 92cbbfecb..90096b0e3 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -23,7 +23,7 @@ export type AuthenticatedDashboardLayoutProps = { export default async function AuthenticatedDashboardLayout({ children, }: AuthenticatedDashboardLayoutProps) { - setupI18nSSR(); + await setupI18nSSR(); const session = await getServerSession(NEXT_AUTH_OPTIONS); diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx index 6d4b350dd..537a0c97b 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx @@ -44,11 +44,11 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => { const isMounted = useIsMounted(); const [interval, setInterval] = useState('month'); - const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false); + const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState(null); const onSubscribeClick = async (priceId: string) => { try { - setIsFetchingCheckoutSession(true); + setCheckoutSessionPriceId(priceId); const url = await createCheckout({ priceId }); @@ -64,7 +64,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => { variant: 'destructive', }); } finally { - setIsFetchingCheckoutSession(false); + setCheckoutSessionPriceId(null); } }; @@ -122,7 +122,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => { +
+ )} + + {data && ( + <> +
    + {data.data.length > 0 && results.totalPages > 1 && ( +
  • +
    +
    +
    + +
    +
    +
    + + +
  • + )} + + {results.data.length === 0 && ( +
    +

    + No recent documents +

    +
    + )} + + {results.data.map((document, documentIndex) => ( +
  • +
    +
    +
    + +
    +
    +
    + + + {match(document.source) + .with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => ( + + Document created by {document.User.name} + + )) + .with(DocumentSource.TEMPLATE_DIRECT_LINK, () => ( + + Document created using a direct link + + )) + .exhaustive()} + + + +
  • + ))} +
+ + + + )} + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recipients.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recipients.tsx new file mode 100644 index 000000000..50a9581e3 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recipients.tsx @@ -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 ( +
+
+

+ Recipients +

+ + + {recipients.length === 0 ? ( + + ) : ( + + )} + +
+ +
    + {recipients.length === 0 && ( +
  • + No recipients +
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    } + secondaryText={ +

    + {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} +

    + } + /> +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx index b67f289a8..0436ab3f6 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx @@ -1,22 +1,28 @@ -import React from 'react'; - import Link from 'next/link'; import { redirect } from 'next/navigation'; import { Trans } from '@lingui/macro'; -import { ChevronLeft } from 'lucide-react'; +import { ChevronLeft, LucideEdit } from 'lucide-react'; -import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; -import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id'; -import { formatTemplatesPath } from '@documenso/lib/utils/teams'; -import type { Team } from '@documenso/prisma/client'; +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; +import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { DocumentSigningOrder, SigningStatus, type Team } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { TemplateType } from '~/components/formatter/template-type'; +import { DataTableActionDropdown } from '../data-table-action-dropdown'; import { TemplateDirectLinkBadge } from '../template-direct-link-badge'; -import { EditTemplateForm } from './edit-template'; +import { UseTemplateDialog } from '../use-template-dialog'; import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper'; +import { TemplatePageViewDocumentsTable } from './template-page-view-documents-table'; +import { TemplatePageViewInformation } from './template-page-view-information'; +import { TemplatePageViewRecentActivity } from './template-page-view-recent-activity'; +import { TemplatePageViewRecipients } from './template-page-view-recipients'; export type TemplatePageViewProps = { params: { @@ -30,6 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) const templateId = Number(id); const templateRootPath = formatTemplatesPath(team?.url); + const documentRootPath = formatDocumentsPath(team?.url); if (!templateId || Number.isNaN(templateId)) { redirect(templateRootPath); @@ -37,29 +44,51 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) const { user } = await getRequiredServerComponentSession(); - const template = await getTemplateWithDetailsById({ + const template = await getTemplateById({ id: templateId, userId: user.id, + teamId: team?.id, }).catch(() => null); - if (!template || !template.templateDocumentData) { + if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) { redirect(templateRootPath); } - const isTemplateEnterprise = await isUserEnterprise({ - userId: user.id, - teamId: team?.id, + const { templateDocumentData, Field, Recipient: recipients, templateMeta } = template; + + // Remap to fit the DocumentReadOnlyFields component. + const readOnlyFields = Field.map((field) => { + const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || { + name: '', + email: '', + signingStatus: SigningStatus.NOT_SIGNED, + }; + + return { + ...field, + Recipient: recipient, + Signature: null, + }; }); - return ( -
-
-
- - - Templates - + const mockedDocumentMeta = templateMeta + ? { + typedSignatureEnabled: false, + ...templateMeta, + signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL, + documentId: 0, + } + : undefined; + return ( +
+ + + Templates + + +
+

{template.title}

@@ -77,17 +106,97 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
-
+
+ +
- +
+ + + + + + + + +
+
+
+
+

+ Template +

+ +
+ +
+
+ +

+ Manage and view template +

+ +
+ + Use + + } + /> +
+
+ + {/* Template information section. */} + + + {/* Recipients section. */} + + + {/* Recent activity section. */} + +
+
+
+ +
+

+ Documents created from template +

+ + +
); }; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx index 0e8b3b2b0..95ca60ae9 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -8,7 +8,7 @@ import { Trans } from '@lingui/macro'; import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates'; +import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client'; import { DropdownMenu, DropdownMenuContent, @@ -23,7 +23,10 @@ import { MoveTemplateDialog } from './move-template-dialog'; import { TemplateDirectLinkDialog } from './template-direct-link-dialog'; export type DataTableActionDropdownProps = { - row: FindTemplateRow; + row: Template & { + directLink?: Pick | null; + Recipient: Recipient[]; + }; templateRootPath: string; teamId?: number; }; @@ -57,7 +60,7 @@ export const DataTableActionDropdown = ({ Action - + Edit diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 0181a5ea7..65213c777 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -124,7 +124,7 @@ export const TemplatesDataTable = ({ accessorKey: 'type', cell: ({ row }) => (
- + {row.original.directLink?.token && ( diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index 8fd11ea03..3ce5e0789 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -73,7 +73,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo setShowNewTemplateDialog(false); - router.push(`${templateRootPath}/${id}`); + router.push(`${templateRootPath}/${id}/edit`); } catch { toast({ title: _(msg`Something went wrong`), diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx index 7f910efa5..886eba88e 100644 --- a/apps/web/src/app/(dashboard)/templates/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -15,8 +15,8 @@ export const metadata: Metadata = { title: 'Templates', }; -export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { - setupI18nSSR(); +export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { + await setupI18nSSR(); return ; } diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index cbf5b5e4a..ee07d8b29 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -15,7 +17,7 @@ import { } from '@documenso/lib/constants/template'; import { AppError } from '@documenso/lib/errors/app-error'; import type { Recipient } from '@documenso/prisma/client'; -import { DocumentSigningOrder } from '@documenso/prisma/client'; +import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -47,7 +49,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; const ZAddRecipientsForNewDocumentSchema = z .object({ - sendDocument: z.boolean(), + distributeDocument: z.boolean(), recipients: z.array( z.object({ id: z.number(), @@ -91,14 +93,18 @@ export type UseTemplateDialogProps = { templateId: number; templateSigningOrder?: DocumentSigningOrder | null; recipients: Recipient[]; + documentDistributionMethod?: DocumentDistributionMethod; documentRootPath: string; + trigger?: React.ReactNode; }; export function UseTemplateDialog({ recipients, + documentDistributionMethod = DocumentDistributionMethod.EMAIL, documentRootPath, templateId, templateSigningOrder, + trigger, }: UseTemplateDialogProps) { const router = useRouter(); @@ -112,7 +118,7 @@ export function UseTemplateDialog({ const form = useForm({ resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), defaultValues: { - sendDocument: false, + distributeDocument: false, recipients: recipients .sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0)) .map((recipient) => { @@ -143,7 +149,7 @@ export function UseTemplateDialog({ templateId, teamId: team?.id, recipients: data.recipients, - sendDocument: data.sendDocument, + distributeDocument: data.distributeDocument, }); toast({ @@ -152,7 +158,16 @@ export function UseTemplateDialog({ duration: 5000, }); - router.push(`${documentRootPath}/${id}`); + let documentPath = `${documentRootPath}/${id}`; + + if ( + data.distributeDocument && + documentDistributionMethod === DocumentDistributionMethod.NONE + ) { + documentPath += '?action=view-signing-links'; + } + + router.push(documentPath); } catch (err) { const error = AppError.parseError(err); @@ -186,10 +201,12 @@ export function UseTemplateDialog({ return ( !form.formState.isSubmitting && setOpen(value)}> - + {trigger || ( + + )} @@ -289,43 +306,76 @@ export function UseTemplateDialog({
(
- + )} + + {documentDistributionMethod === DocumentDistributionMethod.NONE && ( + + )}
)} @@ -341,10 +391,12 @@ export function UseTemplateDialog({ diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx index 69b72bb17..a3c77c15e 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/data-table.tsx @@ -1,5 +1,5 @@ -'use client'; - +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; import { DateTime } from 'luxon'; import type { DateTimeFormatOptions } from 'luxon'; import { UAParser } from 'ua-parser-js'; @@ -25,7 +25,12 @@ const dateFormat: DateTimeFormatOptions = { hourCycle: 'h12', }; +/** + * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. + */ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => { + const { _ } = useLingui(); + const parser = new UAParser(); const uppercaseFistLetter = (text: string) => { @@ -36,11 +41,11 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => { - Time - User - Action - IP Address - Browser + {_(msg`Time`)} + {_(msg`User`)} + {_(msg`Action`)} + {_(msg`IP Address`)} + {_(msg`Browser`)} @@ -74,7 +79,7 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => { - {uppercaseFistLetter(formatDocumentAuditLogAction(log).description)} + {uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)} {log.ipAddress} diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx index 3466b1b1c..f8e510e65 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx @@ -2,13 +2,18 @@ import React from 'react'; import { redirect } from 'next/navigation'; +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; import { DateTime } from 'luxon'; -import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; -import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles'; +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { DOCUMENT_STATUS } from '@documenso/lib/constants/document'; +import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Logo } from '~/components/branding/logo'; @@ -21,7 +26,17 @@ type AuditLogProps = { }; }; +/** + * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. + * + * Cannot use dynamicActivate by itself to translate this specific page and all + * children components because `not-found.tsx` page runs and overrides the i18n. + */ export default async function AuditLog({ searchParams }: AuditLogProps) { + const { i18n } = await setupI18nSSR(); + + const { _ } = useLingui(); + const { d } = searchParams; if (typeof d !== 'string' || !d) { @@ -44,6 +59,10 @@ export default async function AuditLog({ searchParams }: AuditLogProps) { return redirect('/'); } + const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language); + + await dynamicActivate(i18n, documentLanguage); + const { data: auditLogs } = await findDocumentAuditLogs({ documentId: documentId, userId: document.userId, @@ -53,31 +72,35 @@ export default async function AuditLog({ searchParams }: AuditLogProps) { return (
-

Version History

+

{_(msg`Version History`)}

- Document ID + {_(msg`Document ID`)} {document.id}

- Enclosed Document + {_(msg`Enclosed Document`)} {document.title}

- Status + {_(msg`Status`)} - {document.deletedAt ? 'DELETED' : document.status} + + {_( + document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description, + ).toUpperCase()} +

- Owner + {_(msg`Owner`)} {document.User.name} ({document.User.email}) @@ -85,7 +108,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {

- Created At + {_(msg`Created At`)} {DateTime.fromJSDate(document.createdAt) @@ -95,7 +118,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {

- Last Updated + {_(msg`Last Updated`)} {DateTime.fromJSDate(document.updatedAt) @@ -105,7 +128,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {

- Time Zone + {_(msg`Time Zone`)} {document.documentMeta?.timezone ?? 'N/A'} @@ -113,13 +136,13 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {

-

Recipients

+

{_(msg`Recipients`)}

    {document.Recipient.map((recipient) => (
  • - [{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}] + [{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}] {' '} {recipient.name} ({recipient.email})
  • diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx index 8c69de2e9..35c0d0542 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx @@ -2,20 +2,24 @@ import React from 'react'; import { redirect } from 'next/navigation'; +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; -import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n'; import { - RECIPIENT_ROLES_DESCRIPTION_ENG, - RECIPIENT_ROLE_SIGNING_REASONS_ENG, + RECIPIENT_ROLES_DESCRIPTION, + RECIPIENT_ROLE_SIGNING_REASONS, } from '@documenso/lib/constants/recipient-roles'; import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { FieldType } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { @@ -36,11 +40,21 @@ type SigningCertificateProps = { }; const FRIENDLY_SIGNING_REASONS = { - ['__OWNER__']: `I am the owner of this document`, - ...RECIPIENT_ROLE_SIGNING_REASONS_ENG, + ['__OWNER__']: msg`I am the owner of this document`, + ...RECIPIENT_ROLE_SIGNING_REASONS, }; +/** + * DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS. + * + * Cannot use dynamicActivate by itself to translate this specific page and all + * children components because `not-found.tsx` page runs and overrides the i18n. + */ export default async function SigningCertificate({ searchParams }: SigningCertificateProps) { + const { i18n } = await setupI18nSSR(); + + const { _ } = useLingui(); + const { d } = searchParams; if (typeof d !== 'string' || !d) { @@ -63,6 +77,10 @@ export default async function SigningCertificate({ searchParams }: SigningCertif return redirect('/'); } + const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language); + + await dynamicActivate(i18n, documentLanguage); + const auditLogs = await getDocumentCertificateAuditLogs({ id: documentId, }); @@ -98,17 +116,17 @@ export default async function SigningCertificate({ searchParams }: SigningCertif }); let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth) - .with('ACCOUNT', () => 'Account Re-Authentication') - .with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication') - .with('PASSKEY', () => 'Passkey Re-Authentication') - .with('EXPLICIT_NONE', () => 'Email') + .with('ACCOUNT', () => _(msg`Account Re-Authentication`)) + .with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`)) + .with('PASSKEY', () => _(msg`Passkey Re-Authentication`)) + .with('EXPLICIT_NONE', () => _(msg`Email`)) .with(null, () => null) .exhaustive(); if (!authLevel) { authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth) - .with('ACCOUNT', () => 'Account Authentication') - .with(null, () => 'Email') + .with('ACCOUNT', () => _(msg`Account Authentication`)) + .with(null, () => _(msg`Email`)) .exhaustive(); } @@ -147,7 +165,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif return (
    -

    Signing Certificate

    +

    {_(msg`Signing Certificate`)}

    @@ -155,9 +173,9 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
- Signer Events - Signature - Details + {_(msg`Signer Events`)} + {_(msg`Signature`)} + {_(msg`Details`)} {/* Security */} @@ -173,11 +191,11 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
{recipient.name}
{recipient.email}

- {RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName} + {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}

- Authentication Level:{' '} + {_(msg`Authentication Level`)}:{' '} {getAuthenticationLevel(recipient.id)}

@@ -199,21 +217,21 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

- Signature ID:{' '} + {_(msg`Signature ID`)}:{' '} {signature.secondaryId}

- IP Address:{' '} + {_(msg`IP Address`)}:{' '} - {logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'} + {logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}

- Device:{' '} + {_(msg`Device`)}:{' '} {getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)} @@ -227,44 +245,46 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

- Sent:{' '} + {_(msg`Sent`)}:{' '} {logs.EMAIL_SENT[0] ? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : 'Unknown'} + : _(msg`Unknown`)}

- Viewed:{' '} + {_(msg`Viewed`)}:{' '} {logs.DOCUMENT_OPENED[0] ? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : 'Unknown'} + : _(msg`Unknown`)}

- Signed:{' '} + {_(msg`Signed`)}:{' '} {logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') - : 'Unknown'} + : _(msg`Unknown`)}

- Reason:{' '} + {_(msg`Reason`)}:{' '} - {isOwner(recipient.email) - ? FRIENDLY_SIGNING_REASONS['__OWNER__'] - : FRIENDLY_SIGNING_REASONS[recipient.role]} + {_( + isOwner(recipient.email) + ? FRIENDLY_SIGNING_REASONS['__OWNER__'] + : FRIENDLY_SIGNING_REASONS[recipient.role], + )}

@@ -280,7 +300,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif

- Signing certificate provided by: + {_(msg`Signing certificate provided by`)}:

diff --git a/apps/web/src/app/(profile)/layout.tsx b/apps/web/src/app/(profile)/layout.tsx index 43f263de7..d43e44172 100644 --- a/apps/web/src/app/(profile)/layout.tsx +++ b/apps/web/src/app/(profile)/layout.tsx @@ -14,7 +14,7 @@ type PublicProfileLayoutProps = { }; export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) { - setupI18nSSR(); + await setupI18nSSR(); const { user, session } = await getServerComponentSession(); diff --git a/apps/web/src/app/(profile)/p/[url]/page.tsx b/apps/web/src/app/(profile)/p/[url]/page.tsx index e276417d2..67226c5cb 100644 --- a/apps/web/src/app/(profile)/p/[url]/page.tsx +++ b/apps/web/src/app/(profile)/p/[url]/page.tsx @@ -42,7 +42,7 @@ const BADGE_DATA = { }; export default async function PublicProfilePage({ params }: PublicProfilePageProps) { - setupI18nSSR(); + await setupI18nSSR(); const { url: profileUrl } = params; diff --git a/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx index 40118260c..649099717 100644 --- a/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx @@ -1,7 +1,7 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans } from '@lingui/macro'; +import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { useSession } from 'next-auth/react'; import { useForm } from 'react-hook-form'; @@ -77,7 +77,7 @@ export const ConfigureDirectTemplateFormPartial = ({ if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Email cannot already exist in the template', + message: _(msg`Email cannot already exist in the template`), path: ['email'], }); } diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx index 4f9e99fb1..e1c38f3da 100644 --- a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx @@ -7,7 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; -import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { Field } from '@documenso/prisma/client'; import { type Recipient } from '@documenso/prisma/client'; import type { TemplateWithDetails } from '@documenso/prisma/types/template'; @@ -53,7 +53,9 @@ export const DirectTemplatePageView = ({ const [step, setStep] = useState('configure'); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); - const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION_ENG[directTemplateRecipient.role]; + const recipientActionVerb = _( + RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb, + ); const directTemplateFlow: Record = { configure: { @@ -62,9 +64,8 @@ export const DirectTemplatePageView = ({ stepIndex: 1, }, sign: { - // Todo: Translations - title: msg`${recipientRoleDescription.actionVerb} document`, - description: msg`${recipientRoleDescription.actionVerb} the document to complete the process.`, + title: msg`${recipientActionVerb} document`, + description: msg`${recipientActionVerb} the document to complete the process.`, stepIndex: 2, }, }; diff --git a/apps/web/src/app/(recipient)/d/[token]/page.tsx b/apps/web/src/app/(recipient)/d/[token]/page.tsx index 0cc3b1a2c..a2ace350c 100644 --- a/apps/web/src/app/(recipient)/d/[token]/page.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/page.tsx @@ -24,7 +24,7 @@ export type TemplatesDirectPageProps = { }; export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) { - setupI18nSSR(); + await setupI18nSSR(); const { token } = params; diff --git a/apps/web/src/app/(recipient)/layout.tsx b/apps/web/src/app/(recipient)/layout.tsx index e24c7d088..54477b458 100644 --- a/apps/web/src/app/(recipient)/layout.tsx +++ b/apps/web/src/app/(recipient)/layout.tsx @@ -19,7 +19,7 @@ type RecipientLayoutProps = { * Such as direct template access, or signing. */ export default async function RecipientLayout({ children }: RecipientLayoutProps) { - setupI18nSSR(); + await setupI18nSSR(); const { user, session } = await getServerComponentSession(); diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx index 0798e5098..23a5f1278 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx @@ -8,8 +8,8 @@ export type SigningLayoutProps = { children: React.ReactNode; }; -export default function SigningLayout({ children }: SigningLayoutProps) { - setupI18nSSR(); +export default async function SigningLayout({ children }: SigningLayoutProps) { + await setupI18nSSR(); return (
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 312be12fa..b9f677003 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -41,7 +41,7 @@ export type CompletedSigningPageProps = { export default async function CompletedSigningPage({ params: { token }, }: CompletedSigningPageProps) { - setupI18nSSR(); + await setupI18nSSR(); const { _ } = useLingui(); diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index b3f3a0587..8085234db 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -124,9 +124,9 @@ export const SigningForm = ({ >

- {recipient.role === RecipientRole.VIEWER && 'View Document'} - {recipient.role === RecipientRole.SIGNER && 'Sign Document'} - {recipient.role === RecipientRole.APPROVER && 'Approve Document'} + {recipient.role === RecipientRole.VIEWER && View Document} + {recipient.role === RecipientRole.SIGNER && Sign Document} + {recipient.role === RecipientRole.APPROVER && Approve Document}

{recipient.role === RecipientRole.VIEWER ? ( @@ -166,7 +166,7 @@ export const SigningForm = ({ ) : ( <>

- Please review the document before signing. + Please review the document before signing.


@@ -174,7 +174,9 @@ export const SigningForm = ({
- +
- + @@ -213,7 +217,7 @@ export const SigningForm = ({ disabled={typeof window !== 'undefined' && window.history.length <= 1} onClick={() => router.back()} > - Cancel + Cancel { const router = useRouter(); const { toast } = useToast(); + const { _ } = useLingui(); const { fullName } = useRequiredSigningContext(); const initials = extractInitials(fullName); @@ -83,8 +86,8 @@ export const InitialsField = ({ console.error(err); toast({ - title: 'Error', - description: 'An error occurred while signing the document.', + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -109,8 +112,8 @@ export const InitialsField = ({ console.error(err); toast({ - title: 'Error', - description: 'An error occurred while removing the signature.', + title: _(msg`Error`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } @@ -126,7 +129,7 @@ export const InitialsField = ({ {!field.inserted && (

- Initials + Initials

)} diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx index c9be8130b..9ecb8487b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/layout.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx @@ -13,7 +13,7 @@ export type SigningLayoutProps = { }; export default async function SigningLayout({ children }: SigningLayoutProps) { - setupI18nSSR(); + await setupI18nSSR(); const { user, session } = await getServerComponentSession(); diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 214b013ce..ec32082db 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -31,7 +31,7 @@ export type SigningPageProps = { }; export default async function SigningPage({ params: { token } }: SigningPageProps) { - setupI18nSSR(); + await setupI18nSSR(); if (!token) { return notFound(); @@ -43,12 +43,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); - const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); - - if (!isRecipientsTurn) { - return redirect(`/sign/${token}/waiting`); - } - const [document, fields, recipient, completedFields] = await Promise.all([ getDocumentAndSenderByToken({ token, @@ -69,6 +63,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp return notFound(); } + const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); + + if (!isRecipientsTurn) { + return redirect(`/sign/${token}/waiting`); + } + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ documentAuth: document.authOptions, recipientAuth: recipient.authOptions, @@ -99,6 +99,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const { documentMeta } = document; + if (recipient.signingStatus === SigningStatus.REJECTED) { + return redirect(`/sign/${token}/rejected`); + } + if ( document.status === DocumentStatus.COMPLETED || recipient.signingStatus === SigningStatus.SIGNED diff --git a/apps/web/src/app/(signing)/sign/[token]/reject-document-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/reject-document-dialog.tsx new file mode 100644 index 000000000..547a346d8 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/reject-document-dialog.tsx @@ -0,0 +1,170 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter, useSearchParams } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { Document } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZRejectDocumentFormSchema = z.object({ + reason: z + .string() + .min(5, msg`Please provide a reason`) + .max(500, msg`Reason must be less than 500 characters`), +}); + +type TRejectDocumentFormSchema = z.infer; + +export interface RejectDocumentDialogProps { + document: Pick; + token: string; +} + +export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) { + const { toast } = useToast(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: rejectDocumentWithToken } = + trpc.recipient.rejectDocumentWithToken.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZRejectDocumentFormSchema), + defaultValues: { + reason: '', + }, + }); + + const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => { + try { + // TODO: Add trpc mutation here + await rejectDocumentWithToken({ + documentId: document.id, + token, + reason, + }); + + toast({ + title: 'Document rejected', + description: 'The document has been successfully rejected.', + duration: 5000, + }); + + setIsOpen(false); + + router.push(`/sign/${token}/rejected`); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while rejecting the document. Please try again.', + variant: 'destructive', + duration: 5000, + }); + } + }; + + useEffect(() => { + if (searchParams?.get('reject') === 'true') { + setIsOpen(true); + } + }, []); + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen]); + + return ( + + + + + + + + + Reject Document + + + + + Are you sure you want to reject this document? This action cannot be undone. + + + + +
+ + ( + + +