Compare commits

..

68 Commits

Author SHA1 Message Date
8e443b1795 fix: team member invites 2025-02-27 14:21:42 +11:00
ad520bb032 fix: remove oauth from embeds 2025-02-27 14:08:59 +11:00
596d30e2e5 fix: remove lazy pdf loader 2025-02-26 21:48:06 +11:00
6474b4a524 fix: add preferred team middleware 2025-02-26 19:42:42 +11:00
5b4db51051 fix: react-pdf canvas build 2025-02-26 18:39:21 +11:00
cf58c80e31 fix: handle empty field meta for checkboxes 2025-02-26 15:30:51 +11:00
11dbb8873e docs: add the v2 api staging base url (#1671) 2025-02-26 15:30:32 +11:00
bc7907271b fix: unbreak build for docker 2025-02-25 21:46:51 +11:00
9b376d34cf feat: add stripe dev cli 2025-02-25 21:22:28 +11:00
deea99d865 feat: search by externalId 2025-02-25 20:07:47 +11:00
3328074f51 fix: early adopters can use platform features 2025-02-25 20:07:40 +11:00
5e69665e00 fix: rr7 github build 2025-02-25 16:52:10 +11:00
c1c7cfaf8b chore: cleanup 2025-02-25 16:37:36 +11:00
7e8955b89c fix: add posthog error monitor 2025-02-25 15:14:45 +11:00
cedd5e87b1 chore: update API documentation 2025-02-25 02:36:08 +11:00
5255e8671f chore: refactor pdf worker loader 2025-02-24 21:47:06 +11:00
d4c1bad407 fix: add default oauth user url 2025-02-23 18:49:22 +11:00
01dccb7916 chore: flattern routes 2025-02-21 15:53:23 +11:00
483d7caef7 feat: allow document rejection in embeds (#1662) 2025-02-21 01:27:03 +11:00
139bc265c7 fix: migrate billing to RR7 2025-02-21 01:16:23 +11:00
991ce5ff46 fix: update teams API tokens logic 2025-02-21 00:34:50 +11:00
7728c8641c fix: share opengraph 2025-02-20 15:38:06 +11:00
50a41d0799 fix: pdf viewer and embeds 2025-02-20 15:06:36 +11:00
250381fec8 fix: billing 2025-02-20 12:17:55 +11:00
d2f3d24542 chore: update docs 2025-02-19 22:36:17 +11:00
ec07092bf6 fix: session refresh 2025-02-19 22:29:30 +11:00
63e2ef0abf fix: static caching 2025-02-19 21:35:35 +11:00
90ce52164c chore: add password tests 2025-02-19 18:41:53 +11:00
ac30654913 fix: add auth session lifetime 2025-02-19 18:04:36 +11:00
24f3ecd94f fix: remove marketing url 2025-02-19 16:45:54 +11:00
a319ea0f5e fix: add public profiles tests 2025-02-19 16:07:04 +11:00
5ce2bae39d fix: resolve internal pdf translations 2025-02-19 14:43:35 +11:00
5d86e84217 fix: prepare auth migration (#1648)
Add schema session migration in preparation for auth migration.
2025-02-18 15:19:42 +11:00
79e26a9a46 fix: remove session migration 2025-02-18 15:19:39 +11:00
dd602a7e1c fix: themes 2025-02-18 15:17:13 +11:00
fb16214dc5 chore: add asssitant role to the docs (#1638) 2025-02-17 23:28:00 +11:00
5fc724b247 fix: rework sessions 2025-02-17 22:46:36 +11:00
1ed1cb0773 chore: refactor sessions 2025-02-16 00:44:01 +11:00
8d5fafec27 feat: add static cache 2025-02-15 00:08:36 +11:00
0f6f236e0c fix: firefox fouc 2025-02-14 23:02:45 +11:00
e518985833 fix: migrate 2fa to custom auth 2025-02-14 22:00:55 +11:00
595e901bc2 fix: make auth migration more flexible 2025-02-14 19:22:11 +11:00
df8ea09021 fix: add oidc env variables 2025-02-14 18:11:54 +11:00
180656978b feat: add themes 2025-02-14 17:50:23 +11:00
28f5177064 fix: dialogs with search params 2025-02-14 16:14:02 +11:00
31de86e425 feat: add oidc 2025-02-14 16:01:16 +11:00
113ab293bb chore: make all the docker stuff work 2025-02-14 14:53:01 +11:00
1c4878e526 fix: documentation build 2025-02-13 21:21:51 +11:00
92db4d68db fix: cleanup env variables 2025-02-13 20:56:44 +11:00
7379391f92 fix: migrate translations 2025-02-13 20:24:27 +11:00
ebc2b00067 fix: add sign up hook 2025-02-13 20:21:23 +11:00
87dcdd44cd chore: update local seed data (#1622)
Add multiple example documents, pending documents, and templates for
both admin and example users
2025-02-13 19:50:05 +11:00
b205f7e5f3 fix: typed signature not working (#1635)
The `typedSignatureEnabled` prop was removed from the `SignatureField`
component, which broke the typed signature meaning that nobody could
sign documents by typing their signature.
2025-02-13 19:47:38 +11:00
c5d5355cf7 fix: assistant mode breaks for number fields 2025-02-13 19:46:14 +11:00
5fac29a07f fix: add css targets for embeds 2025-02-13 19:45:54 +11:00
1aaacab6ca fix: temp field label/text truncation (#1565)
TEMP: Fix the truncation of the field label/text.
2025-02-13 19:43:35 +11:00
06076c1809 fix: unable to check on the checkbox field (#1593)
This change prevents race conditions between state updates and API
operations by updating local state immediately before making async
calls.
2025-02-13 19:42:08 +11:00
c0ae68c28b feat: assistant role (#1588)
Introduces the ability for users with the **Assistant** role to prefill
fields on behalf of other signers. Assistants can fill in various field
types such as text, checkboxes, dates, and more, streamlining the
document preparation process before it reaches the final signers.
2025-02-13 19:37:34 +11:00
3e106c1a2d chore: api v2 docs (#1620)
chore update docs for api v2 announce
2025-02-13 18:49:37 +11:00
741639ee78 fix: improve move to team display logic 2025-02-13 18:49:03 +11:00
0b3638c42c feat: add Polish and Italian (#1618) 2025-02-13 18:48:37 +11:00
0f50110853 fix: create global settings on team creation (#1601)
The global team settings weren't created when creating a new team.

## Changes Made

The global team settings are now created when a new team is created.
2025-02-13 18:47:59 +11:00
b0f8c83134 chore: add cancelled webhook event (#1608) 2025-02-13 18:47:43 +11:00
c9e8a32471 feat: bulk send templates via csv (#1578)
Implements a bulk send feature allowing users to upload a CSV file to
create multiple documents from a template. Includes CSV template
generation, background processing, and email notifications.
2025-02-13 18:44:29 +11:00
84b193d99c fix: tidy document invite email render logic (#1597)
Updates one of our confusing ternaries to use `ts-pattern` for rendering
the conditional blocks making it easy to follow the logic occurring.
2025-02-13 18:34:38 +11:00
b03c5ab1a7 fix: admin leaderboard query sorting (#1548) 2025-02-13 18:32:38 +11:00
9db42accf3 feat: add text align option to fields (#1610)
Adds the ability to align text to the left, center or right for relevant
fields.

Previously text was always centered which can be less desirable.

See attached debug document which has left, center and right text
alignments set for fields.

<img width="614" alt="image"
src="https://github.com/user-attachments/assets/361a030e-813d-458b-9c7a-ff4c9fa5e33c"
/>

N/A

- Added text align option
- Update the insert in pdf method to support different alignments
- Added a debug mode to field insertion

- Ran manual tests using the debug mode
2025-02-13 18:28:33 +11:00
383b5f78f0 feat: migrate nextjs to rr7 2025-02-13 14:10:38 +11:00
321 changed files with 27479 additions and 16222 deletions

View File

@ -1,5 +1,4 @@
# [[AUTH]]
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
@ -19,14 +18,10 @@ NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
# This can be used to still allow signups for OIDC connections
# when signup is disabled via `NEXT_PUBLIC_DISABLE_SIGNUP`
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP=""
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
# URL used by the web app to request itself (e.g. local background jobs)
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
@ -113,13 +108,9 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
# [[BACKGROUND JOBS]]
NEXT_PRIVATE_JOBS_PROVIDER="local"
NEXT_PRIVATE_TRIGGER_API_KEY=
NEXT_PRIVATE_TRIGGER_API_URL=
NEXT_PRIVATE_INNGEST_EVENT_KEY=
# [[FEATURES]]
@ -135,10 +126,5 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# This is only required for the marketing site
# [[REDIS]]
NEXT_PRIVATE_REDIS_URL=
NEXT_PRIVATE_REDIS_TOKEN=
# [[LOGGER]]
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=

View File

@ -3,7 +3,7 @@ description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v20.x
default: v22.x
runs:
using: 'composite'
steps:

View File

@ -2,7 +2,7 @@ name: 'Setup node and cache node_modules'
inputs:
node_version:
required: false
default: v20.x
default: v22.x
runs:
using: 'composite'

View File

@ -1,7 +1,7 @@
name: Playwright Tests
on:
push:
branches: ['main']
branches: ['main', 'feat/rr7']
pull_request:
branches: ['main']
jobs:

View File

@ -4,9 +4,7 @@ tasks:
npm run dx:up &&
cp .env.example .env &&
set -a; source .env &&
export NEXTAUTH_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
command: npm run d
ports:

View File

@ -4,12 +4,9 @@
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
echo "Copying pdf.js"
npm run copy:pdfjs --workspace apps/**
echo "Copying .well-known/ contents"
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
git add "$MONOREPO_ROOT/apps/web/public/"
git add "$MONOREPO_ROOT/apps/remix/public/"
npx lint-staged

View File

@ -1,7 +1,3 @@
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso&#0045;platform&#0045;plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso&#0032;Platform&#0032;Plan - Whitelabeled&#0032;signing&#0032;flows&#0032;in&#0032;your&#0032;product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
@ -73,9 +69,9 @@ Contact us if you are interested in our Enterprise plan for large organizations
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
## Tech Stack
<p align="left">
<a href="https://www.typescriptlang.org"><img src="https://shields.io/badge/TypeScript-3178C6?logo=TypeScript&logoColor=FFF&style=flat-square" alt="TypeScript"></a>
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/next.js-000000?style=flat-square&logo=nextdotjs&logoColor=white" alt="NextJS"></a>
<a href="https://prisma.io"><img width="122" height="20" src="http://made-with.prisma.io/indigo.svg" alt="Made with Prisma" /></a>
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/tailwindcss-0F172A?&logo=tailwindcss" alt="Tailwind CSS"></a>
<a href=""><img src="" alt=""></a>
@ -85,20 +81,17 @@ Contact us if you are interested in our Enterprise plan for large organizations
<a href=""><img src="" alt=""></a>
</p>
- [Typescript](https://www.typescriptlang.org/) - Language
- [Next.js](https://nextjs.org/) - Framework
- [ReactRouter](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [NextAuth.js](https://next-auth.js.org/) - Authentication
- [react-email](https://react.email/) - Email Templates
- [tRPC](https://trpc.io/) - API
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments
- [Vercel](https://vercel.com) - Hosting
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
@ -108,7 +101,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v18 or above)
- Node.js (v22 or above)
- Postgres SQL Database
- Docker (optional)
@ -171,10 +164,8 @@ git clone https://github.com/<your-username>/documenso
4. Set the following environment variables:
- NEXTAUTH_URL
- NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL
- NEXT_PUBLIC_MARKETING_URL
- NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME
@ -243,16 +234,14 @@ cp .env.example .env
The following environment variables must be set:
- `NEXTAUTH_URL`
- `NEXTAUTH_SECRET`
- `NEXT_PUBLIC_WEBAPP_URL`
- `NEXT_PUBLIC_MARKETING_URL`
- `NEXT_PRIVATE_DATABASE_URL`
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
- `NEXT_PRIVATE_SMTP_FROM_NAME`
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for both `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
Now you can install the dependencies and build it:

View File

@ -1,7 +1,7 @@
import { Link } from 'react-router';
import Link from 'next/link';
import { Button } from '../primitives/button';
import { Card, CardContent } from '../primitives/card';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
type CallToActionProps = {
className?: string;
@ -25,7 +25,7 @@ export const CallToAction = ({ className, utmSource = 'generic-cta' }: CallToAct
size="lg"
asChild
>
<Link to={`https://app.documenso.com/signup?utm_source=${utmSource}`} target="_blank">
<Link href={`https://app.documenso.com/signup?utm_source=${utmSource}`} target="_blank">
Get started
</Link>
</Button>

View File

@ -7,8 +7,7 @@
"build": "next build",
"start": "next start -p 3002",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
"clean": "rimraf .next && rimraf node_modules"
},
"dependencies": {
"@documenso/assets": "*",

View File

@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally.
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
## CSS Class Targets
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
### Component Classes
| Class Name | Description |
| --------------------------------- | ----------------------------------------------------------------------- |
| `.embed--Root` | Main container for the embedded signing experience |
| `.embed--DocumentContainer` | Container for the document and signing widget |
| `.embed--DocumentViewer` | Container for the document viewer |
| `.embed--DocumentWidget` | The signing widget container |
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
Field components also expose several data attributes that can be used for styling different states:
| Data Attribute | Values | Description |
| ------------------- | ---------------------------------------------- | ------------------------------------ |
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
### Field Styling Example
```css
/* Style all field containers */
.field--FieldRootContainer {
transition: all 200ms ease;
}
/* Style specific field types */
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
background-color: rgba(0, 0, 0, 0.02);
}
/* Style inserted fields */
.field--FieldRootContainer[data-inserted='true'] {
background-color: var(--primary);
opacity: 0.2;
}
/* Style fields being validated */
.field--FieldRootContainer[data-validate='true'] {
border-color: orange;
}
```
### Example Usage
```css
/* Custom styles for the document widget */
.embed--DocumentWidget {
background-color: #ffffff;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* Custom styles for the waiting screen */
.embed--WaitingForTurn {
background-color: #f9fafb;
padding: 2rem;
}
/* Responsive adjustments for the document container */
@media (min-width: 768px) {
.embed--DocumentContainer {
gap: 2rem;
}
}
```
## Related
- [React Integration](/developers/embedding/react)

View File

@ -16,18 +16,16 @@ Pick the one that fits your needs the best.
## Tech Stack
- [Typescript](https://www.typescriptlang.org/) - Language
- [Next.js](https://nextjs.org/) - Framework
- [React Router](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [NextAuth.js](https://next-auth.js.org/) - Authentication
- [react-email](https://react.email/) - Email Templates
- [tRPC](https://trpc.io/) - API
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
- [Stripe](https://stripe.com/) - Payments
- [Vercel](https://vercel.com) - Hosting
<div className="mt-16 flex items-center justify-center gap-4">
<a href="https://documen.so/discord">

View File

@ -32,10 +32,8 @@ Run `npm i` in the root directory to install the dependencies required for the p
Set up the following environment variables in the `.env` file:
```bash
NEXTAUTH_URL
NEXTAUTH_SECRET
NEXT_PUBLIC_WEBAPP_URL
NEXT_PUBLIC_MARKETING_URL
NEXT_PRIVATE_DATABASE_URL
NEXT_PRIVATE_DIRECT_DATABASE_URL
NEXT_PRIVATE_SMTP_FROM_NAME

View File

@ -13,35 +13,13 @@ Documenso uses the following stack to handle translations:
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
## Requirements
You **must** insert **`setupI18nSSR()`** when creating any of the following files:
- Server layout.tsx
- Server page.tsx
- Server loading.tsx
Server meaning it does not have `'use client'` in it.
```tsx
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function SomePage() {
setupI18nSSR(); // Required if there are translations within the page, or nested in components.
// Rest of code...
}
```
Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui)
## Quick guide
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
### HTML
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/react/macro**.
```html
<h1>
@ -64,8 +42,9 @@ For text that is broken into elements, but represent a whole sentence, you must
### Constants outside of react components
```tsx
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
export const CONSTANT_WITH_MSG = {
@ -98,31 +77,13 @@ Lingui provides a Plural component to make it easy. See full documentation [here
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
#### Server components
Note that the i18n instance is coming from **setupI18nSSR**.
```tsx
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
export const SomeComponent = () => {
const { i18n } = setupI18nSSR();
const { i18n } = useLingui();
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
};
```
#### Client components
Note that the i18n instance is coming from the **import**.
```tsx
import { i18n } from '@lingui/core';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
export const SomeComponent = () => {
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
};
```

View File

@ -3,6 +3,8 @@ title: Public API
description: Learn how to interact with your documents programmatically using the Documenso public API.
---
import { Callout, Steps } from 'nextra/components';
# Public API
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
@ -13,10 +15,35 @@ Documenso provides a public REST API enabling you to interact with your document
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
## Swagger Documentation
## API V1 - Stable
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
## API V2 - Beta
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
Our new API V2 supports the following typed SDKs:
- [TypeScript](https://github.com/documenso/sdk-typescript)
- [Python](https://github.com/documenso/sdk-python)
- [Go](https://github.com/documenso/sdk-go)
<Callout type="info">
For the staging API, please use the following base URL:
`https://stg-app.documenso.dev/api/v2-beta/`
</Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog)
📖 [Documentation](https://documen.so/api-v2-docs)
💬 [Leave Feedback](https://documen.so/sdk-feedback)
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
## Availability
The API is available to individual users and teams.
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.

View File

@ -5,7 +5,7 @@ description: Learn how to self-host Documenso on your server or cloud infrastruc
import { Callout, Steps } from 'nextra/components';
import { CallToAction } from '@documenso/ui/components/call-to-action';
import { CallToAction } from '../../../components/call-to-action';
# Self Hosting
@ -35,10 +35,8 @@ cp .env.example .env
Open the `.env` file and fill in the following variables:
```bash
- NEXTAUTH_URL
- NEXTAUTH_SECRET
- NEXT_PUBLIC_WEBAPP_URL
- NEXT_PUBLIC_MARKETING_URL
- NEXT_PRIVATE_DATABASE_URL
- NEXT_PRIVATE_DIRECT_DATABASE_URL
- NEXT_PRIVATE_SMTP_FROM_NAME
@ -46,8 +44,8 @@ Open the `.env` file and fill in the following variables:
```
<Callout type="info">
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for both
the `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for the
`NEXT_PUBLIC_WEBAPP_URL` variable!
</Callout>
### Install the Dependencies
@ -171,7 +169,6 @@ Run the Docker container with the required environment variables:
```bash
docker run -d \
-p 3000:3000 \
-e NEXTAUTH_URL="<your-nextauth-url>"
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
@ -200,7 +197,6 @@ The environment variables listed above are a subset of those available for confi
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_URL` | The URL for the NextAuth.js authentication service. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |

View File

@ -3,7 +3,7 @@ title: Getting Started with Self-Hosting
description: A step-by-step guide to setting up and hosting your own Documenso instance.
---
import { CallToAction } from '@documenso/ui/components/call-to-action';
import { CallToAction } from '../../../components/call-to-action';
# Getting Started with Self-Hosting

View File

@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.signed`
- `document.completed`
- `document.rejected`
- `document.cancelled`
## Create a webhook subscription
@ -37,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
@ -528,6 +529,96 @@ Example payload for the `document.rejected` event:
}
```
Example payload for the `document.rejected` event:
```json
{
"event": "DOCUMENT_CANCELLED",
"payload": {
"id": 7,
"externalId": null,
"userId": 3,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "cm6exvn93006hi02ru90a265a",
"createdAt": "2025-01-27T11:02:14.393Z",
"updatedAt": "2025-01-27T11:03:16.387Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "cm6exvn96006ji02rqvzjvwoy",
"subject": "",
"message": "",
"timezone": "Etc/UTC",
"password": null,
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": {
"documentDeleted": true,
"documentPending": true,
"recipientSigned": true,
"recipientRemoved": true,
"documentCompleted": true,
"ownerDocumentCompleted": true,
"recipientSigningRequest": true
}
},
"recipients": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "mybirihix@mailinator.com",
"name": "Zorita Baird",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
],
"Recipient": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2025-01-27T11:03:27.730Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Availability
Webhooks are available to individual users and teams.

View File

@ -86,11 +86,12 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields

View File

@ -7,8 +7,7 @@
"build": "next build",
"start": "next start",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
"clean": "rimraf .next && rimraf node_modules"
},
"dependencies": {
"@documenso/prisma": "*",

View File

@ -1,9 +1,18 @@
#!/usr/bin/env bash
#!/usr/bin/env sh
# Exit on error.
set -eo pipefail
cd "$(dirname "$0")/.."
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."
# Store the original directory
ORIGINAL_DIR=$(pwd)
# Set up trap to ensure we return to original directory
trap 'cd "$ORIGINAL_DIR"' EXIT
cd "$WEB_APP_DIR"
start_time=$(date +%s)

77
apps/remix/.bin/stripe-dev.sh Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Set Error handling
set -eu
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."
# Store the original directory
ORIGINAL_DIR=$(pwd)
# Set up trap to ensure we return to original directory
trap 'cd "$ORIGINAL_DIR"' EXIT
cd "$WEB_APP_DIR"
# Define env file paths
ENV_LOCAL_FILE="../../.env.local"
# Function to load environment variable from env files
load_env_var() {
local var_name=$1
local var_value=""
if [ -f "$ENV_LOCAL_FILE" ]; then
var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2)
fi
# Remove quotes if present
var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/")
echo "$var_value"
}
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED")
# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true
if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then
echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled."
exit 1
fi
# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files
NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY")
# Check if NEXT_PRIVATE_STRIPE_API_KEY exists
if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then
echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files."
echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE"
exit 1
fi
# 2. Check if stripe CLI is installed
if ! command -v stripe &> /dev/null; then
echo "[ERROR]: Stripe CLI is not installed or not in PATH."
echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli"
exit 1
fi
# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET")
if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
echo "╔═════════════════════════════════════════════════════════════════════╗"
echo "║ ║"
echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║"
echo "║ ║"
echo "║ Copy the webhook signing secret which will appear in the terminal ║"
echo "║ soon into the env file. ║"
echo "║ ║"
echo "║ The webhook secret will start with whsec_... ║"
echo "║ ║"
echo "╚═════════════════════════════════════════════════════════════════════╝"
fi
echo "[INFO]: Starting Stripe webhook listener..."
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook

View File

@ -0,0 +1,73 @@
import { Trans } from '@lingui/react/macro';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
type ConfirmationDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
hasUninsertedFields: boolean;
isSubmitting: boolean;
};
export function AssistantConfirmationDialog({
isOpen,
onClose,
onConfirm,
hasUninsertedFields,
isSubmitting,
}: ConfirmationDialogProps) {
const onOpenChange = () => {
if (isSubmitting) {
return;
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone. Please
ensure that you have completed prefilling all relevant fields before proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<DocumentSigningDisclosure />
</div>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant={hasUninsertedFields ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={isSubmitting}
loading={isSubmitting}
>
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -13,7 +13,7 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
@ -97,7 +97,7 @@ export const DocumentDuplicateDialog = ({
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<LazyPDFViewer key={document?.id} documentData={documentData} />
<PDFViewer key={document?.id} documentData={documentData} />
</div>
)}

View File

@ -37,7 +37,7 @@ export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentM
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {

View File

@ -119,7 +119,7 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open, setOpen, updateSearchParams]);
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();

View File

@ -65,7 +65,7 @@ export const TeamTransferDialog = ({
const {
data,
refetch: refetchTeamMembers,
isLoading: loadingTeamMembers,
isPending: loadingTeamMembers,
isLoadingError: loadingTeamMembersError,
} = trpc.team.getTeamMembers.useQuery({
teamId,

View File

@ -0,0 +1,274 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZBulkSendFormSchema = z.object({
file: z.instanceof(File),
sendImmediately: z.boolean().default(false),
});
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
trigger?: React.ReactNode;
onSuccess?: () => void;
};
export const TemplateBulkSendDialog = ({
templateId,
recipients,
trigger,
onSuccess,
}: TemplateBulkSendDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
sendImmediately: false,
},
});
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [
`recipient_${index + 1}_email`,
`recipient_${index + 1}_name`,
]);
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
const csv = [headers.join(','), exampleRow.join(',')].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href: url,
download: 'template.csv',
});
a.click();
window.URL.revokeObjectURL(url);
};
const onSubmit = async (values: TBulkSendFormSchema) => {
try {
const csv = await values.file.text();
await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
toast({
title: _(msg`Success`),
description: _(
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
),
});
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
variant: 'destructive',
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button>
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Bulk Send Template via CSV</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Upload a CSV file to create multiple documents from this template. Each row represents
one document with its recipient details.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<div className="bg-muted/70 rounded-lg border p-4">
<h3 className="text-sm font-medium">
<Trans>CSV Structure</Trans>
</h3>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>
For each recipient, provide their email (required) and name (optional) in separate
columns. Download the template CSV below for the correct format.
</Trans>
</p>
<p className="mt-4 text-sm">
<Trans>Current recipients:</Trans>
</p>
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
{recipients.map((recipient, index) => (
<li key={index}>
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-y-2">
<Button onClick={onDownloadTemplate} variant="outline" type="button">
<Trans>Download Template CSV</Trans>
</Button>
<p className="text-muted-foreground text-xs">
<Trans>Pre-formatted CSV template with example data.</Trans>
</p>
</div>
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormItem>
<FormControl>
{!value ? (
<Button asChild variant="outline" className="w-full">
<label className="cursor-pointer">
<input
type="file"
accept=".csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
disabled={form.formState.isSubmitting}
/>
<Upload className="mr-2 h-4 w-4" />
<Trans>Upload CSV</Trans>
</label>
</Button>
) : (
<div className="flex h-10 items-center rounded-md border px-3">
<div className="flex flex-1 items-center gap-2">
<FileIcon className="text-muted-foreground h-4 w-4" />
<span className="flex-1 truncate text-sm">{value.name}</span>
</div>
<Button
type="button"
variant="link"
className="text-destructive hover:text-destructive p-0 text-xs"
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
<span className="sr-only">
<Trans>Remove</Trans>
</span>
</Button>
</div>
)}
</FormControl>
{error && <p className="text-destructive text-sm">{error.message}</p>}
<p className="text-muted-foreground text-xs">
<Trans>
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
template defaults.
</Trans>
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sendImmediately"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="send-immediately"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="send-immediately"
className="text-muted-foreground ml-2 flex items-center text-sm"
>
<Trans>Send documents to recipients immediately</Trans>
</label>
</div>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Upload and Process</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -83,7 +83,6 @@ export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogP
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
>
<DialogTrigger asChild>
{/* Todo: Wouldn't this break for google? */}
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>New Template</Trans>

View File

@ -76,7 +76,11 @@ export const TemplateDirectLinkDialog = ({
);
const validDirectTemplateRecipients = useMemo(
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
() =>
template.recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
),
[template.recipients],
);

View File

@ -30,22 +30,20 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = {
teamId?: number;
token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void;
children?: React.ReactNode;
};
export default function TokenDeleteDialog({
teamId,
token,
onDelete,
children,
}: TokenDeleteDialogProps) {
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const [isOpen, setIsOpen] = useState(false);
const deleteMessage = _(msg`delete ${token.name}`);
@ -75,7 +73,7 @@ export default function TokenDeleteDialog({
try {
await deleteTokenMutation({
id: token.id,
teamId,
teamId: team?.id,
});
toast({

View File

@ -230,14 +230,13 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</div>
</DialogFooter>
</fieldset>
</form>

View File

@ -8,11 +8,17 @@ import { BrandingLogo } from '~/components/general/branding-logo';
export type EmbedAuthenticationRequiredProps = {
email?: string;
returnTo: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
};
export const EmbedAuthenticationRequired = ({
email,
returnTo,
// isGoogleSSOEnabled,
// isOIDCSSOEnabled,
// oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => {
return (
<div className="flex min-h-[100dvh] w-full items-center justify-center">
@ -28,7 +34,15 @@ export const EmbedAuthenticationRequired = ({
</AlertDescription>
</Alert>
<SignInForm className="mt-4" initialEmail={email} returnTo={returnTo} />
<SignInForm
// Embed currently not supported.
// isGoogleSSOEnabled={isGoogleSSOEnabled}
// isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel}
className="mt-4"
initialEmail={email}
returnTo={returnTo}
/>
</div>
</div>
);

View File

@ -25,7 +25,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -47,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean;
allowWhiteLabelling?: boolean;
};
export const EmbedDirectTemplateClientPage = ({
@ -58,7 +58,7 @@ export const EmbedDirectTemplateClientPage = ({
fields,
metadata,
hidePoweredBy = false,
isPlatformOrEnterprise = false,
allowWhiteLabelling = false,
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -286,7 +286,7 @@ export const EmbedDirectTemplateClientPage = ({
document.documentElement.classList.add('dark-mode-disabled');
}
if (isPlatformOrEnterprise) {
if (allowWhiteLabelling) {
injectCss({
css: data.css,
cssVars: data.cssVars,
@ -338,7 +338,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
<PDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
@ -483,7 +483,6 @@ export const EmbedDirectTemplateClientPage = ({
{/* Fields */}
<EmbedDocumentFields
recipient={recipient}
fields={localFields}
metadata={metadata}
onSignField={onSignField}

View File

@ -12,7 +12,7 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">
<Trans>Document Completed!</Trans>
</h3>

View File

@ -1,4 +1,4 @@
import type { DocumentMeta, Recipient, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
@ -31,7 +31,6 @@ import { DocumentSigningSignatureField } from '~/components/general/document-sig
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
export type EmbedDocumentFieldsProps = {
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
@ -39,7 +38,6 @@ export type EmbedDocumentFieldsProps = {
};
export const EmbedDocumentFields = ({
recipient,
fields,
metadata,
onSignField,
@ -53,7 +51,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningSignatureField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
@ -63,7 +60,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningInitialsField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -72,7 +68,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningNameField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -81,7 +76,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningDateField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
@ -92,7 +86,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningEmailField
key={field.id}
field={field}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -107,7 +100,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningTextField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -123,7 +115,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningNumberField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -139,7 +130,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningRadioField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -155,7 +145,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningCheckboxField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -171,7 +160,6 @@ export const EmbedDocumentFields = ({
<DocumentSigningDropdownField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>

View File

@ -0,0 +1,33 @@
import { Trans } from '@lingui/react/macro';
import { XCircle } from 'lucide-react';
export const EmbedDocumentRejected = () => {
return (
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
</div>
</div>
);
};

View File

@ -1,15 +1,22 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useEffect, useId, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Recipient, TemplateMeta } from '@prisma/client';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import {
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
@ -17,7 +24,8 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -26,20 +34,24 @@ import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = {
token: string;
documentId: number;
documentData: DocumentData;
recipient: Recipient;
recipient: RecipientWithFields;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean;
allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[];
};
export const EmbedSignDocumentClientPage = ({
@ -51,7 +63,8 @@ export const EmbedSignDocumentClientPage = ({
metadata,
isCompleted,
hidePoweredBy = false,
isPlatformOrEnterprise = false,
allowWhitelabelling = false,
allRecipients = [],
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -69,17 +82,26 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [
fields.filter((field) => !field.inserted),
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
fields.filter((field) => field.inserted),
];
@ -88,6 +110,8 @@ export const EmbedSignDocumentClientPage = ({
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId();
const onNextFieldClick = () => {
validateFieldsInserted(fields);
@ -149,6 +173,25 @@ export const EmbedSignDocumentClientPage = ({
}
};
const onDocumentRejected = (reason: string) => {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token,
documentId,
recipientId: recipient.id,
reason,
},
},
'*',
);
}
setHasRejectedDocument(true);
};
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
@ -162,12 +205,13 @@ export const EmbedSignDocumentClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (isPlatformOrEnterprise) {
if (allowWhitelabelling) {
injectCss({
css: data.css,
cssVars: data.cssVars,
@ -196,6 +240,10 @@ export const EmbedSignDocumentClientPage = ({
}
}, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (hasCompletedDocument) {
return (
<EmbedDocumentCompleted
@ -213,13 +261,24 @@ export const EmbedSignDocumentClientPage = ({
}
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{allowDocumentRejection && (
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
document={{ id: documentId }}
token={token}
onRejected={onDocumentRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
<div className="embed--DocumentViewer flex-1">
<PDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
@ -228,15 +287,19 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div>
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
{isAssistantMode ? (
<Trans>Assist with signing</Trans>
) : (
<Trans>Sign document</Trans>
)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
@ -255,17 +318,77 @@ export const EmbedSignDocumentClientPage = ({
</div>
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
{isAssistantMode ? (
<Trans>Help complete the document for other signers.</Trans>
) : (
<Trans>Sign the document to complete the process.</Trans>
)}
</p>
<hr className="border-border mb-8 mt-4" />
</div>
{/* Form */}
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4">
{isAssistantMode && (
<div>
<Label>
<Trans>Signing for</Trans>
</Label>
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={selectedSignerId?.toString()}
onValueChange={(value) => setSelectedSignerId(Number(value))}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label
className="inline-flex items-start"
htmlFor={`${assistantSignersId}-${r.id}`}
>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
</fieldset>
</div>
)}
{!isAssistantMode && (
<>
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
@ -329,20 +452,24 @@ export const EmbedSignDocumentClientPage = ({
</div>
)}
</div>
</>
)}
</div>
</div>
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
{pendingFields.length > 0 ? (
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
<Trans>Next</Trans>
</Button>
) : (
<Button
className="col-start-2"
disabled={isThrottled || (hasSignatureField && !signatureValid)}
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
@ -362,7 +489,7 @@ export const EmbedSignDocumentClientPage = ({
</ElementVisible>
{/* Fields */}
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
<EmbedDocumentFields fields={fields} metadata={metadata} />
</div>
{!hidePoweredBy && (
@ -372,5 +499,6 @@ export const EmbedSignDocumentClientPage = ({
</div>
)}
</div>
</DocumentSigningRecipientProvider>
);
};

View File

@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
export const EmbedDocumentWaitingForTurn = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {
if (window.parent && !hasPostedMessage) {
window.parent.postMessage(
{
action: 'document-waiting-for-turn',
data: null,
},
'*',
);
}
setHasPostedMessage(true);
}, [hasPostedMessage]);
if (!hasPostedMessage) {
return null;
}
return (
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-center text-2xl font-bold">
<Trans>Waiting for Your Turn</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. Please check back soon as this document should be
available for you to sign shortly.
</Trans>
</p>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
</div>
);
};

View File

@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -47,8 +47,6 @@ export const DisableAuthenticatorAppDialog = () => {
const [isOpen, setIsOpen] = useState(false);
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
const disable2FAForm = useForm<TDisable2FAForm>({
defaultValues: {
totpCode: '',
@ -81,7 +79,7 @@ export const DisableAuthenticatorAppDialog = () => {
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
try {
await disable2FA({ totpCode, backupCode });
await authClient.twoFactor.disable({ totpCode, backupCode });
toast({
title: _(msg`Two-factor authentication disabled`),

View File

@ -9,8 +9,8 @@ import { useRevalidator } from 'react-router';
import { renderSVG } from 'uqr';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -52,24 +52,8 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
const {
mutateAsync: setup2FA,
data: setup2FAData,
isPending: isSettingUp2FA,
} = trpc.twoFactorAuthentication.setup.useMutation({
onError: () => {
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
},
});
const [isSettingUp2FA, setIsSettingUp2FA] = useState(false);
const [setup2FAData, setSetup2FAData] = useState<{ uri: string; secret: string } | null>(null);
const enable2FAForm = useForm<TEnable2FAForm>({
defaultValues: {
@ -80,9 +64,34 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
const setup2FA = async () => {
if (isSettingUp2FA) {
return;
}
setIsSettingUp2FA(true);
setSetup2FAData(null);
try {
const data = await authClient.twoFactor.setup();
setSetup2FAData(data);
} catch (err) {
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
setIsSettingUp2FA(false);
};
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
try {
const data = await enable2FA({ code: token });
const data = await authClient.twoFactor.enable({ code: token });
setRecoveryCodes(data.recoveryCodes);
onSuccess?.();

View File

@ -6,9 +6,9 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -41,20 +41,32 @@ export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
export const ViewRecoveryCodesDialog = () => {
const [isOpen, setIsOpen] = useState(false);
const {
data: recoveryCodes,
mutate,
isPending,
error,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
const form = useForm<TViewRecoveryCodesForm>({
defaultValues: {
token: '',
},
resolver: zodResolver(ZViewRecoveryCodesForm),
});
const onFormSubmit = async ({ token }: TViewRecoveryCodesForm) => {
setError(null);
try {
const data = await authClient.twoFactor.viewRecoveryCodes({
token,
});
setRecoveryCodes(data.backupCodes);
} catch (err) {
const error = AppError.parseError(err);
setError(error.code);
}
};
const downloadRecoveryCodes = () => {
if (recoveryCodes) {
const blob = new Blob([recoveryCodes.join('\n')], {
@ -106,8 +118,8 @@ export const ViewRecoveryCodesDialog = () => {
</DialogFooter>
</div>
) : (
<Form {...viewRecoveryCodesForm}>
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<DialogHeader className="mb-4">
<DialogTitle>
<Trans>View Recovery Codes</Trans>
@ -118,10 +130,10 @@ export const ViewRecoveryCodesDialog = () => {
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isPending}>
<fieldset className="flex flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
name="token"
control={viewRecoveryCodesForm.control}
control={form.control}
render={({ field }) => (
<FormItem>
<FormControl>
@ -161,7 +173,7 @@ export const ViewRecoveryCodesDialog = () => {
</Button>
</DialogClose>
<Button type="submit" loading={isPending}>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>View</Trans>
</Button>
</DialogFooter>

View File

@ -42,7 +42,7 @@ export type AvatarImageFormProps = {
};
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
const { user } = useSession();
const { user, refreshSession } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
@ -103,13 +103,13 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
teamId: team?.id,
});
await refreshSession();
toast({
title: _(msg`Avatar Updated`),
description: _(msg`Your avatar has been updated successfully.`),
duration: 5000,
});
void revalidate();
} catch (err) {
const error = AppError.parseError(err);

View File

@ -122,7 +122,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
<Trans>New Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput autoComplete="new-password" {...field} />

View File

@ -3,7 +3,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -42,8 +41,7 @@ export type ProfileFormProps = {
export const ProfileForm = ({ className }: ProfileFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { revalidate } = useRevalidator();
const { user, refreshSession } = useSession();
const form = useForm<TProfileFormSchema>({
values: {
@ -64,13 +62,13 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
signature,
});
await refreshSession();
toast({
title: _(msg`Profile updated`),
description: _(msg`Your profile has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -1,197 +0,0 @@
import React, { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { User } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '../general/user-profile-skeleton';
export const ZClaimPublicProfileFormSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
});
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
export type ClaimPublicProfileDialogFormProps = {
open: boolean;
onOpenChange?: (open: boolean) => void;
onClaimed?: () => void;
user: User;
};
export const ClaimPublicProfileDialogForm = ({
open,
onOpenChange,
onClaimed,
user,
}: ClaimPublicProfileDialogFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [claimed, setClaimed] = useState(false);
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TClaimPublicProfileFormSchema>({
values: {
url: user.url || '',
},
resolver: zodResolver(ZClaimPublicProfileFormSchema),
});
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
try {
await updatePublicProfile({
url,
});
setClaimed(true);
onClaimed?.();
} catch (err) {
const error = AppError.parseError(err);
if (error.code === 'PROFILE_URL_TAKEN') {
form.setError('url', {
type: 'manual',
message: _(msg`This username is already taken`),
});
} else if (error.code === 'PREMIUM_PROFILE_URL') {
form.setError('url', {
type: 'manual',
message: error.message,
});
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
toast({
title: 'An error occurred',
description: error.userMessage ?? error.message,
variant: 'destructive',
});
} else {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to save your details. Please try again later.`,
),
variant: 'destructive',
});
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent position="center" className="max-w-lg overflow-hidden">
{!claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">
Introducing public profiles!
</DialogTitle>
<DialogDescription className="text-center">
Reserve your Documenso public profile username
</DialogDescription>
</DialogHeader>
<img src={profileClaimTeaserImage} alt="profile claim teaser" />
<Form {...form}>
<form
className={cn(
'to-background -mt-32 flex w-full flex-col bg-gradient-to-b from-transparent to-15% pt-16 md:-mt-44',
)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="-mt-6 flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile username</FormLabel>
<FormControl>
<Input type="text" className="mb-2 mt-2" {...field} />
</FormControl>
<FormMessage />
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'}
</div>
</FormItem>
)}
/>
</fieldset>
<div className="mt-4 text-center">
<Button type="submit" loading={isSubmitting}>
Claim your username
</Button>
</div>
</form>
</Form>
</>
)}
{claimed && (
<>
<DialogHeader>
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
<DialogDescription className="text-center">
We will let you know as soon as this features is launched
</DialogDescription>
</DialogHeader>
<UserProfileSkeleton className="mt-4" user={user} rows={1} />
<div className="to-background -mt-12 flex w-full flex-col items-center bg-gradient-to-b from-transparent to-15% px-4 pt-8 md:-mt-12">
<Button className="w-full" onClick={() => onOpenChange?.(false)}>
Can't wait!
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -257,7 +257,9 @@ export const SignInForm = ({
const onSignInWithGoogleClick = async () => {
try {
await authClient.google.signIn();
await authClient.google.signIn({
redirectPath,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
@ -271,11 +273,9 @@ export const SignInForm = ({
const onSignInWithOIDCClick = async () => {
try {
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 2000));
// await signIn('oidc', {
// callbackUrl,
// });
await authClient.oidc.signIn({
redirectPath,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -36,8 +36,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
import { UserProfileTimur } from '~/components/general/user-profile-timur';
const SIGN_UP_REDIRECT_PATH = '/documents';
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
export const ZSignUpFormSchema = z
@ -191,9 +189,7 @@ export const SignUpForm = ({
const onSignUpWithOIDCClick = async () => {
try {
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 2000));
// await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH });
await authClient.oidc.signIn();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -1,4 +1,4 @@
import { useState, useTransition } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@ -38,6 +38,8 @@ import {
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
@ -59,15 +61,14 @@ type NewlyCreatedToken = {
export type ApiTokenFormProps = {
className?: string;
teamId?: number;
tokens?: Pick<ApiToken, 'id'>[];
};
export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => {
const [isTransitionPending, startTransition] = useTransition();
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard();
const team = useOptionalCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
@ -113,7 +114,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try {
await createTokenMutation({
teamId,
teamId: team?.id,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
@ -238,7 +239,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
type="submit"
className="hidden md:inline-flex"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting || isTransitionPending}
loading={form.formState.isSubmitting}
>
<Trans>Create token</Trans>
</Button>
@ -247,7 +248,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
<Button
type="submit"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting || isTransitionPending}
loading={form.formState.isSubmitting}
>
<Trans>Create token</Trans>
</Button>

View File

@ -80,7 +80,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
{
query: search,

View File

@ -0,0 +1,138 @@
import { useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
type Interval = keyof PriceIntervals;
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
day: msg`Daily`,
week: msg`Weekly`,
month: msg`Monthly`,
year: msg`Yearly`,
};
const MotionCard = motion(Card);
export type BillingPlansProps = {
prices: PriceIntervals;
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const isMounted = useIsMounted();
const [interval, setInterval] = useState<Interval>('month');
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
const { mutateAsync: createCheckoutSession } = trpc.profile.createCheckoutSession.useMutation();
const onSubscribeClick = async (priceId: string) => {
try {
setCheckoutSessionPriceId(priceId);
const url = await createCheckoutSession({ priceId });
if (!url) {
throw new Error('Unable to create session');
}
window.open(url);
} catch (_err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while trying to create a checkout session.`),
variant: 'destructive',
});
} finally {
setCheckoutSessionPriceId(null);
}
};
return (
<div>
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
<TabsList>
{INTERVALS.map(
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{_(FRIENDLY_INTERVALS[interval])}
</TabsTrigger>
),
)}
</TabsList>
</Tabs>
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence mode="wait">
{prices[interval].map((price) => (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-2 text-lg font-medium">
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
<span className="text-xs">per {interval}</span>
</div>
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
<li key={index} className="py-2">
{feature.name}
</li>
))}
</ul>
</div>
)}
<div className="flex-1" />
<Button
className="mt-4"
disabled={checkoutSessionPriceId !== null}
loading={checkoutSessionPriceId === price.id}
onClick={() => void onSubscribeClick(price.id)}
>
<Trans>Subscribe</Trans>
</Button>
</CardContent>
</MotionCard>
))}
</AnimatePresence>
</div>
</div>
);
};

View File

@ -0,0 +1,48 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type BillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
children?: React.ReactNode;
};
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: createBillingPortal, isPending } =
trpc.profile.createBillingPortal.useMutation({
onSuccess: (sessionUrl) => {
window.open(sessionUrl, '_blank');
},
onError: (err) => {
let description = _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
);
if (err.message === 'CUSTOMER_NOT_FOUND') {
description = _(
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
);
}
toast({
title: _(msg`Something went wrong`),
description,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Button {...buttonProps} onClick={async () => createBillingPortal()} loading={isPending}>
{children || <Trans>Manage Subscription</Trans>}
</Button>
);
};

View File

@ -56,7 +56,8 @@ export const DirectTemplateConfigureForm = ({
}: DirectTemplateConfigureFormProps) => {
const { _ } = useLingui();
const { user } = useOptionalSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { recipients } = template;
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();

View File

@ -12,7 +12,7 @@ import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -136,7 +136,7 @@ export const DirectTemplatePageView = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
<PDFViewer
key={template.id}
documentData={template.templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}

View File

@ -51,6 +51,8 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentSigningRecipientProvider } from '../document-signing/document-signing-recipient-provider';
export type DirectTemplateSigningFormProps = {
flowStep: DocumentFlowStep;
directRecipient: Recipient;
@ -169,7 +171,7 @@ export const DirectTemplateSigningForm = ({
};
return (
<>
<DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
@ -186,16 +188,15 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningSignatureField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
<DocumentSigningInitialsField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -204,7 +205,6 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningNameField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -213,7 +213,6 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningDateField
key={field.id}
field={field}
recipient={directRecipient}
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
onSignField={onSignField}
@ -224,7 +223,6 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningEmailField
key={field.id}
field={field}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -241,7 +239,6 @@ export const DirectTemplateSigningForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -259,7 +256,6 @@ export const DirectTemplateSigningForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -277,7 +273,6 @@ export const DirectTemplateSigningForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -295,7 +290,6 @@ export const DirectTemplateSigningForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -313,7 +307,6 @@ export const DirectTemplateSigningForm = ({
...field,
fieldMeta: parsedFieldMeta,
}}
recipient={directRecipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
@ -351,6 +344,7 @@ export const DirectTemplateSigningForm = ({
onChange={(value) => {
setSignature(value);
}}
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
@ -383,6 +377,6 @@ export const DirectTemplateSigningForm = ({
/>
</div>
</DocumentFlowFormContainerFooter>
</>
</DocumentSigningRecipientProvider>
);
};

View File

@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -25,17 +24,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningCheckboxFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningCheckboxField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningCheckboxFieldProps) => {
@ -43,9 +41,16 @@ export const DocumentSigningCheckboxField = ({
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const parsedFieldMeta = ZCheckboxFieldMeta.parse(
field.fieldMeta ?? {
type: 'checkbox',
values: [{ id: 1, checked: false, value: '' }],
},
);
const values = parsedFieldMeta.values?.map((item) => ({
...item,
@ -118,7 +123,9 @@ export const DocumentSigningCheckboxField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -147,7 +154,7 @@ export const DocumentSigningCheckboxField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -179,29 +186,26 @@ export const DocumentSigningCheckboxField = ({
...checkedValues,
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
];
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
if (isLengthConditionMet) {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: toCheckboxValue(checkedValues),
isBase64: true,
});
}
} else {
updatedValues = checkedValues.filter(
(v) => v !== item.value && v !== `empty-value-${item.id}`,
);
}
setCheckedValues(updatedValues);
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
if (updatedValues.length > 0) {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: toCheckboxValue(updatedValues),
isBase64: true,
});
}
} catch (err) {
console.error(err);
@ -212,7 +216,6 @@ export const DocumentSigningCheckboxField = ({
variant: 'destructive',
});
} finally {
setCheckedValues(updatedValues);
await revalidate();
}
};

View File

@ -1,7 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -13,19 +12,21 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZDateFieldMeta } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningDateFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
@ -34,7 +35,6 @@ export type DocumentSigningDateFieldProps = {
export const DocumentSigningDateField = ({
field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
onSignField,
@ -44,6 +44,8 @@ export const DocumentSigningDateField = ({
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
@ -54,10 +56,11 @@ export const DocumentSigningDateField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const safeFieldMeta = ZDateFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTime = field.inserted && localDateString !== field.customText;
const tooltipText = _(
msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone || ''}".`,
);
@ -90,7 +93,9 @@ export const DocumentSigningDateField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -116,7 +121,7 @@ export const DocumentSigningDateField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -143,9 +148,21 @@ export const DocumentSigningDateField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{localDateString}
</p>
</div>
)}
</DocumentSigningFieldContainer>
);

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -28,17 +27,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningDropdownFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningDropdownField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningDropdownFieldProps) => {
@ -46,6 +44,8 @@ export const DocumentSigningDropdownField = ({
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
@ -99,7 +99,9 @@ export const DocumentSigningDropdownField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -131,7 +133,7 @@ export const DocumentSigningDropdownField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}

View File

@ -1,34 +1,34 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningEmailFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningEmailField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningEmailFieldProps) => {
@ -38,6 +38,8 @@ export const DocumentSigningEmailField = ({
const { email: providedEmail } = useRequiredDocumentSigningContext();
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
@ -48,6 +50,9 @@ export const DocumentSigningEmailField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const safeFieldMeta = ZEmailFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
const value = providedEmail ?? '';
@ -79,7 +84,9 @@ export const DocumentSigningEmailField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -105,7 +112,7 @@ export const DocumentSigningEmailField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -126,9 +133,21 @@ export const DocumentSigningEmailField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
</DocumentSigningFieldContainer>
);

View File

@ -44,6 +44,7 @@ export type DocumentSigningFieldContainerProps = {
| 'Email'
| 'Name'
| 'Signature'
| 'Text'
| 'Radio'
| 'Dropdown'
| 'Number'

View File

@ -1,8 +1,10 @@
import { useMemo, useState } from 'react';
import { useId, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
@ -11,6 +13,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
@ -18,8 +21,11 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
@ -29,6 +35,8 @@ export type DocumentSigningFormProps = {
fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
};
export const DocumentSigningForm = ({
@ -37,20 +45,36 @@ export const DocumentSigningForm = ({
fields,
redirectUrl,
isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
}: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const analytics = useAnalytics();
const { user } = useOptionalSession();
const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
useRequiredDocumentSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
selectedSignerId: undefined,
},
});
const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time.
@ -65,7 +89,11 @@ export const DocumentSigningForm = ({
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
}, [fields]);
}, [fieldsRequiringValidation]);
const uninsertedRecipientFields = useMemo(() => {
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
@ -86,12 +114,31 @@ export const DocumentSigningForm = ({
}
await completeDocument();
};
// Reauth is currently not required for completing the document.
// await executeActionAuthProcedure({
// onReauthFormSubmit: completeDocument,
// actionTarget: 'DOCUMENT',
// });
const onAssistantFormSubmit = () => {
if (uninsertedRecipientFields.length > 0) {
return;
}
setIsConfirmationDialogOpen(true);
};
const handleAssistantConfirmDialogSubmit = async () => {
setIsAssistantSubmitting(true);
try {
await completeDocument();
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
setIsAssistantSubmitting(false);
setIsConfirmationDialogOpen(false);
}
};
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
@ -115,7 +162,7 @@ export const DocumentSigningForm = ({
};
return (
<form
<div
className={cn(
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
{
@ -123,7 +170,6 @@ export const DocumentSigningForm = ({
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
},
)}
onSubmit={handleSubmit(onFormSubmit)}
>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
@ -131,17 +177,13 @@ export const DocumentSigningForm = ({
</FieldToolTip>
)}
<fieldset
disabled={isSubmitting}
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<div className={cn('flex flex-1 flex-col')}>
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
<div className="flex flex-1 flex-col">
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
</h3>
{recipient.role === RecipientRole.VIEWER ? (
@ -178,15 +220,108 @@ export const DocumentSigningForm = ({
</div>
</div>
</>
) : recipient.role === RecipientRole.ASSISTANT ? (
<>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
Complete the fields for the following signers. Once reviewed, they will inform
you if any modifications are needed.
</Trans>
</p>
<hr className="border-border my-4" />
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
<Controller
name="selectedSignerId"
control={assistantForm.control}
rules={{ required: 'Please select a signer' }}
render={({ field }) => (
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={field.value?.toString()}
onValueChange={(value) => {
field.onChange(value);
setSelectedSignerId?.(Number(value));
}}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label
className="inline-flex items-start"
htmlFor={`${assistantSignersId}-${r.id}`}
>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
)}
/>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="submit"
className="w-full"
size="lg"
loading={isAssistantSubmitting}
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
>
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
</Button>
</div>
<AssistantConfirmationDialog
hasUninsertedFields={uninsertedFields.length > 0}
isOpen={isConfirmationDialogOpen}
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
/>
</form>
</>
) : (
<>
<form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please review the document before signing.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<fieldset
disabled={isSubmitting}
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
>
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
@ -258,11 +393,12 @@ export const DocumentSigningForm = ({
disabled={!isRecipientsTurn}
/>
</div>
</div>
</fieldset>
</form>
</>
)}
</div>
</fieldset>
</form>
</div>
</div>
);
};

View File

@ -1,7 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -19,17 +18,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningInitialsFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningInitialsField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningInitialsFieldProps) => {
@ -38,6 +36,8 @@ export const DocumentSigningInitialsField = ({
const { revalidate } = useRevalidator();
const { fullName } = useRequiredDocumentSigningContext();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
const initials = extractInitials(fullName);
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
@ -81,7 +81,9 @@ export const DocumentSigningInitialsField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}

View File

@ -3,19 +3,20 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNameFieldMeta } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
@ -25,17 +26,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningNameFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningNameField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningNameFieldProps) => {
@ -46,6 +46,8 @@ export const DocumentSigningNameField = ({
const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredDocumentSigningContext();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
@ -58,11 +60,14 @@ export const DocumentSigningNameField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const safeFieldMeta = ZNameFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const [showFullNameModal, setShowFullNameModal] = useState(false);
const [localFullName, setLocalFullName] = useState('');
const onPreSign = () => {
if (!providedFullName) {
if (!providedFullName && !isAssistantMode) {
setShowFullNameModal(true);
return false;
}
@ -85,9 +90,9 @@ export const DocumentSigningNameField = ({
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
try {
const value = name || providedFullName;
const value = name || providedFullName || '';
if (!value) {
if (!value && !isAssistantMode) {
setShowFullNameModal(true);
return;
}
@ -119,7 +124,9 @@ export const DocumentSigningNameField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -145,7 +152,7 @@ export const DocumentSigningNameField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -172,9 +179,21 @@ export const DocumentSigningNameField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>

View File

@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { Hash, Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -26,6 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type ValidationErrors = {
isNumber: string[];
@ -37,14 +37,12 @@ type ValidationErrors = {
export type DocumentSigningNumberFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningNumberField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningNumberFieldProps) => {
@ -52,10 +50,13 @@ export const DocumentSigningNumberField = ({
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [showRadioModal, setShowRadioModal] = useState(false);
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
const [showNumberModal, setShowNumberModal] = useState(false);
const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
const isReadOnly = parsedFieldMeta?.readOnly;
const defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState(
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
@ -106,7 +107,7 @@ export const DocumentSigningNumberField = ({
};
const onDialogSignClick = () => {
setShowRadioModal(false);
setShowNumberModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -149,14 +150,20 @@ export const DocumentSigningNumberField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
};
const onPreSign = () => {
setShowRadioModal(true);
if (isAssistantMode) {
return true;
}
setShowNumberModal(true);
if (localNumber && parsedFieldMeta) {
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
@ -194,23 +201,23 @@ export const DocumentSigningNumberField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!showRadioModal) {
if (!showNumberModal) {
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
setErrors(initialErrors);
}
}, [showRadioModal]);
}, [showNumberModal]);
useEffect(() => {
if (
(!field.inserted && defaultValue && localNumber) ||
(!field.inserted && isReadOnly && defaultValue)
(!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -223,8 +230,8 @@ export const DocumentSigningNumberField = ({
if (parsedFieldMeta?.label) {
fieldDisplayName =
parsedFieldMeta.label.length > 10
? parsedFieldMeta.label.substring(0, 10) + '...'
parsedFieldMeta.label.length > 20
? parsedFieldMeta.label.substring(0, 20) + '...'
: parsedFieldMeta.label;
}
@ -236,7 +243,7 @@ export const DocumentSigningNumberField = ({
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
type="Number"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
@ -262,12 +269,24 @@ export const DocumentSigningNumberField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
@ -323,7 +342,7 @@ export const DocumentSigningNumberField = ({
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowRadioModal(false);
setShowNumberModal(false);
setLocalNumber('');
}}
>

View File

@ -1,5 +1,7 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
@ -16,9 +18,10 @@ import {
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
@ -35,12 +38,15 @@ import { DocumentSigningSignatureField } from '~/components/general/document-sig
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type SigningPageViewProps = {
document: DocumentAndSender;
recipient: Recipient;
recipient: RecipientWithFields;
fields: Field[];
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
};
export const DocumentSigningPageView = ({
@ -49,9 +55,12 @@ export const DocumentSigningPageView = ({
fields,
completedFields,
isRecipientsTurn,
allRecipients = [],
}: SigningPageViewProps) => {
const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const shouldUseTeamDetails =
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
@ -63,7 +72,10 @@ export const DocumentSigningPageView = ({
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
}
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<div className="mx-auto w-full max-w-screen-xl">
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -106,6 +118,15 @@ export const DocumentSigningPageView = ({
<Trans>has invited you to approve this document</Trans>
),
)
.with(RecipientRole.ASSISTANT, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to assist this document
</Trans>
) : (
<Trans>has invited you to assist this document</Trans>
),
)
.otherwise(() => null)}
</span>
</div>
@ -119,12 +140,7 @@ export const DocumentSigningPageView = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
</CardContent>
</Card>
@ -135,111 +151,90 @@ export const DocumentSigningPageView = ({
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
/>
</div>
</div>
<DocumentReadOnlyFields fields={completedFields} />
{recipient.role !== RecipientRole.ASSISTANT && (
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
)}
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
{fields
.filter(
(field) =>
recipient.role !== RecipientRole.ASSISTANT ||
field.recipientId === selectedSigner?.id,
)
.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<DocumentSigningSignatureField
key={field.id}
field={field}
recipient={recipient}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
<DocumentSigningInitialsField key={field.id} field={field} recipient={recipient} />
<DocumentSigningInitialsField key={field.id} field={field} />
))
.with(FieldType.NAME, () => (
<DocumentSigningNameField key={field.id} field={field} recipient={recipient} />
<DocumentSigningNameField key={field.id} field={field} />
))
.with(FieldType.DATE, () => (
<DocumentSigningDateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<DocumentSigningEmailField key={field.id} field={field} recipient={recipient} />
<DocumentSigningEmailField key={field.id} field={field} />
))
.with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
};
return (
<DocumentSigningTextField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
/>
);
return <DocumentSigningTextField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
};
return (
<DocumentSigningNumberField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
/>
);
return <DocumentSigningNumberField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.RADIO, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
};
return (
<DocumentSigningRadioField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
/>
);
return <DocumentSigningRadioField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
};
return (
<DocumentSigningCheckboxField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
/>
);
return <DocumentSigningCheckboxField key={field.id} field={fieldWithMeta} />;
})
.with(FieldType.DROPDOWN, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
};
return (
<DocumentSigningDropdownField
key={field.id}
field={fieldWithMeta}
recipient={recipient}
/>
);
return <DocumentSigningDropdownField key={field.id} field={fieldWithMeta} />;
})
.otherwise(() => null),
)}
</ElementVisible>
</div>
</DocumentSigningRecipientProvider>
);
};

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -22,17 +21,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningRadioFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningRadioField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningRadioFieldProps) => {
@ -40,6 +38,8 @@ export const DocumentSigningRadioField = ({
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const values = parsedFieldMeta.values?.map((item) => ({
...item,
@ -100,7 +100,9 @@ export const DocumentSigningRadioField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -127,7 +129,7 @@ export const DocumentSigningRadioField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
description: _(msg`An error occurred while removing the selection.`),
variant: 'destructive',
});
}

View File

@ -0,0 +1,67 @@
import { type PropsWithChildren, createContext, useContext } from 'react';
import type { Recipient } from '@prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
export interface DocumentSigningRecipientContextValue {
/**
* The recipient who is currently signing the document.
* In regular mode, this is the actual signer.
* In assistant mode, this is the recipient who is helping fill out the document.
*/
recipient: Recipient | RecipientWithFields;
/**
* Only present in assistant mode.
* The recipient on whose behalf we're filling out the document.
*/
targetSigner: RecipientWithFields | null;
/**
* Whether we're in assistant mode (one recipient filling out for another)
*/
isAssistantMode: boolean;
}
const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientContextValue | null>(
null,
);
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
recipient: Recipient | RecipientWithFields;
targetSigner?: RecipientWithFields | null;
}
export const DocumentSigningRecipientProvider = ({
children,
recipient,
targetSigner = null,
}: DocumentSigningRecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return (
<DocumentSigningRecipientContext.Provider
value={{
recipient,
targetSigner,
isAssistantMode: !!targetSigner,
}}
>
{children}
</DocumentSigningRecipientContext.Provider>
);
};
export function useDocumentSigningRecipientContext() {
const context = useContext(DocumentSigningRecipientContext);
if (!context) {
throw new Error('useDocumentSigningRecipientContext must be used within a RecipientProvider');
}
return context;
}

View File

@ -42,9 +42,14 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
export interface DocumentSigningRejectDialogProps {
document: Pick<Document, 'id'>;
token: string;
onRejected?: (reason: string) => void | Promise<void>;
}
export function DocumentSigningRejectDialog({ document, token }: DocumentSigningRejectDialogProps) {
export function DocumentSigningRejectDialog({
document,
token,
onRejected,
}: DocumentSigningRejectDialogProps) {
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@ -75,9 +80,13 @@ export function DocumentSigningRejectDialog({ document, token }: DocumentSigning
duration: 5000,
});
await navigate(`/sign/${token}/rejected`);
setIsOpen(false);
if (onRejected) {
await onRejected(reason);
} else {
await navigate(`/sign/${token}/rejected`);
}
} catch (err) {
toast({
title: 'Error',

View File

@ -3,7 +3,6 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -27,11 +26,11 @@ import { DocumentSigningDisclosure } from '~/components/general/document-signing
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type DocumentSigningSignatureFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
typedSignatureEnabled?: boolean;
@ -39,7 +38,6 @@ export type DocumentSigningSignatureFieldProps = {
export const DocumentSigningSignatureField = ({
field,
recipient,
onSignField,
onUnsignField,
typedSignatureEnabled,
@ -48,6 +46,8 @@ export const DocumentSigningSignatureField = ({
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { recipient } = useDocumentSigningRecipientContext();
const signatureRef = useRef<HTMLParagraphElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2);

View File

@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { Loader, Type } from 'lucide-react';
import { useRevalidator } from 'react-router';
@ -26,17 +25,27 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
export type DocumentSigningTextFieldProps = {
field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
type ValidationErrors = {
required: string[];
characterLimit: string[];
};
export type TextFieldProps = {
field: FieldWithSignatureAndFieldMeta;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const DocumentSigningTextField = ({
field,
recipient,
onSignField,
onUnsignField,
}: DocumentSigningTextFieldProps) => {
@ -44,11 +53,12 @@ export const DocumentSigningTextField = ({
const { toast } = useToast();
const { revalidate } = useRevalidator();
const initialErrors: Record<string, string[]> = {
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
const initialErrors: ValidationErrors = {
required: [],
characterLimit: [],
};
const [errors, setErrors] = useState(initialErrors);
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
@ -62,7 +72,8 @@ export const DocumentSigningTextField = ({
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const shouldAutoSignField =
@ -165,7 +176,9 @@ export const DocumentSigningTextField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
@ -193,7 +206,7 @@ export const DocumentSigningTextField = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the text.`),
description: _(msg`An error occurred while removing the field.`),
variant: 'destructive',
});
}
@ -233,7 +246,7 @@ export const DocumentSigningTextField = ({
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
type="Text"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
@ -261,11 +274,23 @@ export const DocumentSigningTextField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 15) + '...'}
: field.customText.substring(0, 20) + '...'}
</p>
</div>
)}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
@ -281,6 +306,10 @@ export const DocumentSigningTextField = ({
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})}
value={localText}
onChange={handleTextChange}

View File

@ -24,7 +24,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -132,9 +132,6 @@ export const DocumentEditForm = ({
},
});
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
settings: {
title: msg`General`,
@ -315,13 +312,6 @@ export const DocumentEditForm = ({
}
};
const onPasswordSubmit = async (password: string) => {
await setPasswordForDocument({
documentId: document.id,
password,
});
};
const currentDocumentFlow = documentFlow[step];
/**
@ -340,12 +330,10 @@ export const DocumentEditForm = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
<PDFViewer
key={document.documentData.id}
documentData={document.documentData}
document={document}
password={document.documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>

View File

@ -351,6 +351,16 @@ export const DocumentHistorySheet = ({
/>
),
)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field prefilled',
value: formatGenericText(data.field.type),
},
]}
/>
))
.exhaustive()}
{isUserDetailsVisible && (

View File

@ -139,10 +139,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={isDeleted}>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>

View File

@ -11,6 +11,7 @@ import {
MailOpenIcon,
PenIcon,
PlusIcon,
UserIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@ -118,6 +119,12 @@ export const DocumentPageViewRecipients = ({
<Trans>Viewed</Trans>
</>
))
.with(RecipientRole.ASSISTANT, () => (
<>
<UserIcon className="mr-1 h-3 w-3" />
<Trans>Assisted</Trans>
</>
))
.exhaustive()}
</Badge>
)}

View File

@ -67,7 +67,7 @@ export const DocumentRecipientLinkCopyDialog = ({
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open, setOpen, updateSearchParams]);
}, [actionSearchParam, open]);
return (
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>

View File

@ -85,7 +85,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
timestamp: new Date().toISOString(),
});
void navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);

View File

@ -8,22 +8,37 @@ import { Link, useNavigate } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useOptionalCurrentTeam } from '~/providers/team';
type ErrorCodeMap = Record<
number,
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
>;
export type GenericErrorLayoutProps = {
children?: React.ReactNode;
errorCode?: number;
errorCodeMap?: ErrorCodeMap;
/**
* The primary button to display. If left as undefined, the default /documents link will be displayed.
*
* Set to null if you want no button.
*/
primaryButton?: React.ReactNode | null;
/**
* The secondary button to display. If left as undefined, the default back button will be displayed.
*
* Set to null if you want no button.
*/
secondaryButton?: React.ReactNode | null;
};
export const ErrorLayoutCodes: Record<
number,
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
> = {
export const defaultErrorCodeMap: ErrorCodeMap = {
404: {
subHeading: msg`404 Page not found`,
subHeading: msg`404 not found`,
heading: msg`Oops! Something went wrong.`,
message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`,
},
@ -34,17 +49,23 @@ export const ErrorLayoutCodes: Record<
},
};
export const GenericErrorLayout = ({ children, errorCode }: GenericErrorLayoutProps) => {
export const GenericErrorLayout = ({
children,
errorCode,
errorCodeMap = defaultErrorCodeMap,
primaryButton,
secondaryButton,
}: GenericErrorLayoutProps) => {
const navigate = useNavigate();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { subHeading, heading, message } =
ErrorLayoutCodes[errorCode || 404] ?? ErrorLayoutCodes[404];
errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500];
return (
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">
<div className="absolute -inset-24 -z-10">
<motion.div
className="flex h-full w-full items-center justify-center"
@ -63,7 +84,7 @@ export const GenericErrorLayout = ({ children, errorCode }: GenericErrorLayoutPr
</motion.div>
</div>
<div className="container mx-auto flex h-full min-h-screen items-center justify-center px-6 py-32">
<div className="inset-0 mx-auto flex h-full flex-grow items-center justify-center px-6 py-32">
<div>
<p className="text-muted-foreground font-semibold">{_(subHeading)}</p>
@ -72,6 +93,8 @@ export const GenericErrorLayout = ({ children, errorCode }: GenericErrorLayoutPr
<p className="text-muted-foreground mt-4 text-sm">{_(message)}</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
{secondaryButton ||
(secondaryButton !== null && (
<Button
variant="ghost"
className="w-32"
@ -82,12 +105,16 @@ export const GenericErrorLayout = ({ children, errorCode }: GenericErrorLayoutPr
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Button>
))}
{primaryButton ||
(primaryButton !== null && (
<Button asChild>
<Link to={formatDocumentsPath(team?.url)}>
<Trans>Documents</Trans>
</Link>
</Button>
))}
{children}
</div>

View File

@ -10,9 +10,9 @@ import { Link, useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
@ -262,7 +262,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
{selectedTeam &&
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${selectedTeam.url}/settings/`}>
<Link to={`/t/${selectedTeam.url}/settings`}>
<Trans>Team settings</Trans>
</Link>
</DropdownMenuItem>

View File

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
type PortalComponentProps = {
children: React.ReactNode;
target: string;
};
export const PortalComponent = ({ children, target }: PortalComponentProps) => {
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
useEffect(() => {
setPortalRoot(document.getElementById(target));
}, [target]);
return portalRoot ? createPortal(children, portalRoot) : null;
};

View File

@ -3,10 +3,12 @@ import { useCallback, useEffect } from 'react';
import { useRevalidator } from 'react-router';
export const RefreshOnFocus = () => {
const { revalidate } = useRevalidator();
const { revalidate, state } = useRevalidator();
const onFocus = useCallback(() => {
if (state === 'idle') {
void revalidate();
}
}, [revalidate]);
useEffect(() => {

View File

@ -3,8 +3,8 @@ import React from 'react';
import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = {
title: string;
subtitle: string;
title: string | React.ReactNode;
subtitle: string | React.ReactNode;
hideDivider?: boolean;
children?: React.ReactNode;
className?: string;

View File

@ -4,7 +4,7 @@ import { Link } from 'react-router';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function Loading() {
export default function DocumentEditSkeleton() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link to="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { type Subscription, SubscriptionStatus } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { AlertTriangle } from 'lucide-react';
import { match } from 'ts-pattern';
@ -22,13 +22,13 @@ import {
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamLayoutBillingBannerProps = {
subscription: Subscription;
subscriptionStatus: SubscriptionStatus;
teamId: number;
userRole: TeamMemberRole;
};
export const TeamLayoutBillingBanner = ({
subscription,
subscriptionStatus,
teamId,
userRole,
}: TeamLayoutBillingBannerProps) => {
@ -59,7 +59,7 @@ export const TeamLayoutBillingBanner = ({
}
};
if (subscription.status === SubscriptionStatus.ACTIVE) {
if (subscriptionStatus === SubscriptionStatus.ACTIVE) {
return null;
}
@ -68,16 +68,16 @@ export const TeamLayoutBillingBanner = ({
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
subscription.status === SubscriptionStatus.PAST_DUE,
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground':
subscription.status === SubscriptionStatus.INACTIVE,
subscriptionStatus === SubscriptionStatus.INACTIVE,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{match(subscription.status)
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Teams restricted</Trans>)
.exhaustive()}
@ -87,9 +87,9 @@ export const TeamLayoutBillingBanner = ({
variant="ghost"
className={cn({
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
subscription.status === SubscriptionStatus.PAST_DUE,
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
subscription.status === SubscriptionStatus.INACTIVE,
subscriptionStatus === SubscriptionStatus.INACTIVE,
})}
disabled={isPending}
onClick={() => setIsOpen(true)}
@ -106,7 +106,7 @@ export const TeamLayoutBillingBanner = ({
<Trans>Payment overdue</Trans>
</DialogTitle>
{match(subscription.status)
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => (
<DialogDescription>
<Trans>

View File

@ -15,7 +15,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@ -236,7 +236,7 @@ export const TemplateEditForm = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
<PDFViewer
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react';
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -52,7 +52,15 @@ export const AdminLeaderboardTable = ({
onClick={() => handleColumnSort('name')}
>
{_(msg`Name`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
{sortBy === 'name' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
),
accessorKey: 'name',
@ -78,7 +86,15 @@ export const AdminLeaderboardTable = ({
onClick={() => handleColumnSort('signingVolume')}
>
{_(msg`Signing Volume`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
{sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
),
accessorKey: 'signingVolume',
@ -92,7 +108,15 @@ export const AdminLeaderboardTable = ({
onClick={() => handleColumnSort('createdAt')}
>
{_(msg`Created`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
{sortBy === 'createdAt' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div>
);
},
@ -100,7 +124,7 @@ export const AdminLeaderboardTable = ({
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder]);
}, [sortOrder, sortBy]);
useEffect(() => {
startTransition(() => {
@ -131,6 +155,9 @@ export const AdminLeaderboardTable = ({
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
startTransition(() => {
updateSearchParams({
search: debouncedSearchString,
page,
perPage,
sortBy: column,
sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc',
});

View File

@ -157,7 +157,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
</DropdownMenuItem>
{/* We don't want to allow teams moving documents across at the moment. */}
{!team && (
{!team && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans>
@ -170,10 +170,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
Void
</DropdownMenuItem> */}
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
@ -14,6 +14,7 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
@ -82,13 +83,24 @@ export const TemplatesTableActionDropdown = ({
<Trans>Direct link</Trans>
</DropdownMenuItem>
{!teamId && (
{!teamId && !row.teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}

View File

@ -1,13 +1,30 @@
import { StrictMode, startTransition } from 'react';
import { StrictMode, startTransition, useEffect } from 'react';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import posthog from 'posthog-js';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
function PosthogInit() {
const postHogConfig = extractPostHogConfig();
useEffect(() => {
if (postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
}
}, []);
return null;
}
async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en';
@ -20,6 +37,8 @@ async function main() {
<I18nProvider i18n={i18n}>
<HydratedRouter />
</I18nProvider>
<PosthogInit />
</StrictMode>,
);
});

View File

@ -1,47 +0,0 @@
import { useEffect } from 'react';
import posthog from 'posthog-js';
import { useLocation, useSearchParams } from 'react-router';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function PostHogPageview() {
const postHogConfig = extractPostHogConfig();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const { user } = useOptionalSession();
if (typeof window !== 'undefined' && postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
disable_session_recording: true,
loaded: () => {
if (user) {
posthog.identify(user.email ?? user.id.toString());
} else {
posthog.reset();
}
},
custom_campaign_params: ['src'],
});
}
useEffect(() => {
if (!postHogConfig || !pathname) {
return;
}
let url = window.origin + pathname;
if (searchParams && searchParams.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture('$pageview', {
$current_url: url,
});
}, [pathname, searchParams, postHogConfig]);
return null;
}

View File

@ -1,14 +1,16 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { TGetTeamByUrlResponse } from '@documenso/lib/server-only/team/get-team';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
type TeamProviderValue = TGetTeamsResponse[0];
interface TeamProviderProps {
children: React.ReactNode;
team: TGetTeamByUrlResponse;
team: TeamProviderValue;
}
const TeamContext = createContext<TGetTeamByUrlResponse | null>(null);
const TeamContext = createContext<TeamProviderValue | null>(null);
export const useCurrentTeam = () => {
const context = useContext(TeamContext);

View File

@ -1,6 +1,7 @@
import { Suspense, useEffect } from 'react';
import { useEffect } from 'react';
import Plausible from 'plausible-tracker';
import posthog from 'posthog-js';
import {
Links,
Meta,
@ -12,12 +13,13 @@ import {
useLoaderData,
useLocation,
} from 'react-router';
import { ThemeProvider } from 'remix-themes';
import { getOptionalLoaderSession } from 'server/utils/get-loader-session';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { createPublicEnv } from '@documenso/lib/utils/env';
import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
import { createPublicEnv, env } from '@documenso/lib/utils/env';
import { extractLocaleData } from '@documenso/lib/utils/i18n';
import { TrpcProvider } from '@documenso/trpc/react';
import { Toaster } from '@documenso/ui/primitives/toaster';
@ -27,13 +29,13 @@ import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { RefreshOnFocus } from './components/general/refresh-on-focus';
import { PostHogPageview } from './providers/posthog';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
const { trackPageview } = Plausible({
domain: 'documenso.com',
trackLocalhost: false,
});
export const links: Route.LinksFunction = () => [
@ -58,8 +60,21 @@ export function meta() {
return appMetaTags();
}
/**
* Don't revalidate (run the loader on sequential navigations) on the root layout
*
* Update values via providers.
*/
export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) {
const session = getOptionalLoaderSession();
const session = await getOptionalSession(request);
let teams: TGetTeamsResponse = [];
if (session.isAuthenticated) {
teams = await getTeams({ userId: session.user.id });
}
const { getTheme } = await themeSessionResolver(request);
@ -73,7 +88,13 @@ export async function loader({ request }: Route.LoaderArgs) {
{
lang,
theme: getTheme(),
session,
session: session.isAuthenticated
? {
user: session.user,
session: session.session,
teams,
}
: null,
publicEnv: createPublicEnv(),
},
{
@ -85,12 +106,30 @@ export async function loader({ request }: Route.LoaderArgs) {
}
export function Layout({ children }: { children: React.ReactNode }) {
const { publicEnv, theme, lang } = useLoaderData<typeof loader>() || {};
const { theme } = useLoaderData<typeof loader>() || {};
// const [theme] = useTheme();
const location = useLocation();
useEffect(() => {
if (env('NODE_ENV') === 'production') {
trackPageview();
}
}, [location.pathname]);
return (
<html translate="no" lang={lang} data-theme={theme ?? ''}>
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
<LayoutContent>{children}</LayoutContent>
</ThemeProvider>
);
}
export function LayoutContent({ children }: { children: React.ReactNode }) {
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
const [theme] = useTheme();
return (
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
<head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@ -102,18 +141,25 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Meta />
<Links />
<meta name="google" content="notranslate" />
{/* <PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} /> */}
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
<Suspense>
<PostHogPageview />
</Suspense>
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script>0</script>
</head>
<body>
<SessionProvider initialSession={session}>
<TooltipProvider>
<TrpcProvider>
{children}
<Toaster />
</TrpcProvider>
</TooltipProvider>
</SessionProvider>
<ScrollRestoration />
<Scripts />
{/* Todo: Do we want this here? */}
<RefreshOnFocus />
<script
@ -126,38 +172,16 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
export default function App({ loaderData }: Route.ComponentProps) {
const location = useLocation();
useEffect(() => {
trackPageview();
}, [location.pathname]);
return (
<SessionProvider session={loaderData.session}>
{/* Todo: Themes (this won't work for now) */}
<ThemeProvider specifiedTheme={loaderData.theme} themeAction="/api/theme">
<TooltipProvider>
<TrpcProvider>
<Outlet />
<Toaster />
</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</SessionProvider>
);
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
console.error('[RootErrorBoundary]', error);
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (isRouteErrorResponse(error)) {
console.log(error.data);
console.log(error.status);
console.log(error.statusText);
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error);
posthog.captureException(error);
}
return <GenericErrorLayout errorCode={errorCode} />;

View File

@ -1,8 +1,9 @@
import { Outlet } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { Outlet, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getLimits } from '@documenso/ee/server-only/limits/client';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
@ -12,33 +13,44 @@ import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const { user, teams, currentTeam } = getLoaderSession();
/**
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
*/
export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) {
const requestHeaders = Object.fromEntries(request.headers.entries());
// Todo: Should only load this on first render.
const session = await getOptionalSession(request);
if (!session.isAuthenticated) {
throw redirect('/signin');
}
const [limits, banner] = await Promise.all([
getLimits({ headers: requestHeaders, teamId: currentTeam?.id }),
getLimits({ headers: requestHeaders }),
getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
),
]);
return {
user,
teams,
banner,
limits,
teamId: currentTeam?.id,
};
};
}
export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams, banner, limits, teamId } = loaderData;
const { user, teams } = useSession();
const { banner, limits } = loaderData;
return (
<LimitsProvider initialValue={limits} teamId={teamId}>
<LimitsProvider initialValue={limits}>
<div id="portal-header"></div>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && <AppBanner banner={banner} />}

View File

@ -1,14 +1,16 @@
import { Trans } from '@lingui/react/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export function loader() {
const { user } = getLoaderSession();
import type { Route } from './+types/_layout';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/documents');

View File

@ -31,9 +31,6 @@ import type { Route } from './+types/documents.$id';
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.id);
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
if (isNaN(id)) {
throw redirect('/admin/documents');

View File

@ -3,19 +3,20 @@ import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge';
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 { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
@ -32,10 +33,16 @@ import {
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id._index';
import type { Route } from './+types/documents.$id._index';
export async function loader({ params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
@ -81,27 +88,7 @@ export async function loader({ params }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
const { documentMeta } = document;
// Todo: We don't handle encrypted files right.
// if (documentMeta?.password) {
// const key = DOCUMENSO_ENCRYPTION_KEY;
// if (!key) {
// throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
// }
// const securePassword = Buffer.from(
// symmetricDecrypt({
// key,
// data: documentMeta.password,
// }),
// ).toString('utf-8');
// documentMeta.password = securePassword;
// }
// Todo: Get full document instead???
// Todo: Get full document instead?
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
@ -209,7 +196,7 @@ export default function DocumentPage() {
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
</CardContent>
</Card>

View File

@ -2,11 +2,12 @@ import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@ -15,10 +16,16 @@ import { DocumentStatus } from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/$id.edit';
import type { Route } from './+types/documents.$id.edit';
export async function loader({ params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getLoaderSession();
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
@ -68,24 +75,6 @@ export async function loader({ params }: Route.LoaderArgs) {
throw redirect(`${documentRootPath}/${documentId}`);
}
// Todo: We don't handle encrypted files right.
// if (documentMeta?.password) {
// const key = DOCUMENSO_ENCRYPTION_KEY;
// if (!key) {
// throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
// }
// const securePassword = Buffer.from(
// symmetricDecrypt({
// key,
// data: documentMeta.password,
// }),
// ).toString('utf-8');
// documentMeta.password = securePassword;
// }
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,

View File

@ -6,10 +6,11 @@ import type { Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card';
@ -21,13 +22,19 @@ import {
} from '~/components/general/document/document-status';
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/$id.logs';
import type { Route } from './+types/documents.$id.logs';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
export async function loader({ params }: Route.LoaderArgs) {
const { id } = params;
const { user, currentTeam: team } = getLoaderSession();
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
@ -36,7 +43,7 @@ export async function loader({ params }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
// Todo: Get detailed?
// Todo: Get full document instead?
const [document, recipients] = await Promise.all([
getDocumentById({
documentId,

View File

@ -59,15 +59,26 @@ export default function DocumentsPage() {
[searchParams],
);
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery({
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
});
},
);
// Refetch the documents when the team URL changes.
useEffect(() => {
void refetch();
}, [team?.url]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
if (params.has('page')) {
params.delete('page');
}

View File

@ -0,0 +1,157 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { BillingPlans } from '~/components/general/billing-plans';
import { BillingPortalButton } from '~/components/general/billing-portal-button';
import { appMetaTags } from '~/utils/meta';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/billing';
export function meta() {
return appMetaTags('Billing');
}
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// Redirect if subscriptions are not enabled.
if (!IS_BILLING_ENABLED()) {
throw redirect('/settings/profile');
}
if (!user.customerId) {
await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
getPrimaryAccountPlanPrices(),
]);
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
primaryAccountPlanPriceIds.includes(priceId),
);
const subscription =
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
primaryAccountPlanSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null,
);
}
const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
return superLoaderJson({
prices,
subscription,
subscriptionProductName: subscriptionProduct?.name,
isMissingOrInactiveOrFreePlan,
});
}
export default function TeamsSettingBillingPage() {
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
useSuperLoaderData<typeof loader>();
const { i18n } = useLingui();
return (
<div>
<div className="flex flex-row items-end justify-between">
<div>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
<Trans>
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
)}
{/* Todo: Translation */}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProductName ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProductName}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
) : (
<span>
automatically renew on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
)}
</span>
)}
</p>
))
.with('PAST_DUE', () => (
<p>
<Trans>
Your current plan is past due. Please update your payment information.
</Trans>
</p>
))
.otherwise(() => null)}
</div>
</div>
{isMissingOrInactiveOrFreePlan && (
<BillingPortalButton>
<Trans>Manage billing</Trans>
</BillingPortalButton>
)}
</div>
<hr className="my-4" />
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More