Compare commits

..

3 Commits

987 changed files with 39794 additions and 57802 deletions

View File

@ -1,4 +1,5 @@
# [[AUTH]]
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
@ -18,10 +19,14 @@ 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"
@ -108,9 +113,13 @@ 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]]
@ -126,5 +135,10 @@ 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

@ -5,7 +5,6 @@ module.exports = {
rules: {
'@next/next/no-img-element': 'off',
'no-unreachable': 'error',
'react-hooks/exhaustive-deps': 'off',
},
settings: {
next: {

View File

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

View File

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

View File

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

View File

@ -4,7 +4,9 @@ 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,9 +4,12 @@
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/remix/public/"
git add "$MONOREPO_ROOT/apps/web/public/"
npx lint-staged

View File

@ -1,3 +1,7 @@
> 🚨 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">
@ -69,9 +73,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>
@ -81,17 +85,20 @@ 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
- [ReactRouter](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Next.js](https://nextjs.org/) - 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. -->
@ -101,7 +108,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v22 or above)
- Node.js (v18 or above)
- Postgres SQL Database
- Docker (optional)
@ -164,8 +171,10 @@ 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
@ -234,14 +243,16 @@ 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 the `NEXT_PUBLIC_WEBAPP_URL` variable!
> 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!
Now you can install the dependencies and build it:

View File

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

View File

@ -14,4 +14,4 @@
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
}

View File

@ -111,83 +111,6 @@ 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,16 +16,18 @@ Pick the one that fits your needs the best.
## Tech Stack
- [Typescript](https://www.typescriptlang.org/) - Language
- [React Router](https://reactrouter.com/) - Framework
- [Next.js](https://nextjs.org/) - 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,8 +32,10 @@ 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,13 +13,35 @@ 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/react/macro**.
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
```html
<h1>
@ -42,9 +64,8 @@ For text that is broken into elements, but represent a whole sentence, you must
### Constants outside of react components
```tsx
import { msg } from '@lingui/core/macro';
import { Trans, msg } from '@lingui/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 = {
@ -77,13 +98,31 @@ 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 } = useLingui();
const { i18n } = setupI18nSSR();
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,8 +3,6 @@ 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:
@ -15,30 +13,10 @@ 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.
## API V1 - Stable
## Swagger Documentation
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)
🚀 [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)
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
## Availability
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.
The API is available to individual users and teams.

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 '../../../components/call-to-action';
import { CallToAction } from '@documenso/ui/components/call-to-action';
# Self Hosting
@ -35,8 +35,10 @@ 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
@ -44,8 +46,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 the
`NEXT_PUBLIC_WEBAPP_URL` variable!
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!
</Callout>
### Install the Dependencies
@ -169,6 +171,7 @@ 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>"
@ -197,6 +200,7 @@ 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 '../../../components/call-to-action';
import { CallToAction } from '@documenso/ui/components/call-to-action';
# Getting Started with Self-Hosting

View File

@ -21,7 +21,6 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.signed`
- `document.completed`
- `document.rejected`
- `document.cancelled`
## Create a webhook subscription
@ -38,7 +37,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`, `document.cancelled`.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
- 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)
@ -529,96 +528,6 @@ 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

@ -85,13 +85,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 |
| 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 |
| 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 |
### Fields

View File

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

View File

@ -1,37 +0,0 @@
#!/usr/bin/env sh
# Exit on error.
set -eo pipefail
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)
echo "[Build]: Extracting and compiling translations"
npm run translate --prefix ../../
echo "[Build]: Building app"
npm run build:app
echo "[Build]: Building server"
npm run build:server
# Copy over the entry point for the server.
cp server/main.js build/server/main.js
# Copy over all web.js translations
cp -r ../../packages/lib/translations build/server/hono/packages/lib/translations
# Time taken
end_time=$(date +%s)
echo "[Build]: Done in $((end_time - start_time)) seconds"

View File

@ -1,77 +0,0 @@
#!/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

@ -1,4 +0,0 @@
.react-router
build
node_modules
README.md

View File

@ -1,9 +0,0 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
# Vite
vite.config.*.timestamp*

View File

@ -1,22 +0,0 @@
FROM node:20-alpine AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev
FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

View File

@ -1,25 +0,0 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]

View File

@ -1,26 +0,0 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]

View File

@ -1,100 +0,0 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Installation
Install the dependencies:
```bash
npm install
```
### Development
Start the development server with HMR:
```bash
npm run dev
```
Your application will be available at `http://localhost:5173`.
## Building for Production
Create a production build:
```bash
npm run build
```
## Deployment
### Docker Deployment
This template includes three Dockerfiles optimized for different package managers:
- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun
To build and run using Docker:
```bash
# For npm
docker build -t my-app .
# For pnpm
docker build -f Dockerfile.pnpm -t my-app .
# For bun
docker build -f Dockerfile.bun -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
The containerized application can be deployed to any platform that supports Docker, including:
- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway
### DIY Deployment
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│ ├── client/ # Static assets
│ └── server/ # Server-side code
```
## Styling
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
---
Built with ❤️ using React Router.

View File

@ -1,24 +0,0 @@
@import '@documenso/ui/styles/theme.css';
@font-face {
font-family: 'Inter';
src: url('/public/fonts/inter-regular.ttf') format('ttf');
/* font-weight: 400;
font-style: normal;
font-display: swap; */
}
@font-face {
font-family: 'Caveat';
src: url('/public/fonts/caveat.ttf') format('ttf');
/* font-weight: 400;
font-style: normal;
font-display: swap; */
}
@layer base {
:root {
--font-sans: 'Inter';
--font-signature: 'Caveat';
}
}

View File

@ -1,73 +0,0 @@
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

@ -1,274 +0,0 @@
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

@ -1,48 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { SignInForm } from '~/components/forms/signin';
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">
<div className="flex w-full max-w-md flex-col">
<BrandingLogo className="h-8" />
<Alert className="mt-8" variant="warning">
<AlertDescription>
<Trans>
To view this document you need to be signed into your account, please sign in to
continue.
</Trans>
</AlertDescription>
</Alert>
<SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
className="mt-4"
initialEmail={email}
returnTo={returnTo}
/>
</div>
</div>
);
};

View File

@ -1,33 +0,0 @@
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,504 +0,0 @@
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, 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';
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 { 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 { BrandingLogo } from '~/components/general/branding-logo';
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: RecipientWithFields;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[];
};
export const EmbedSignDocumentClientPage = ({
token,
documentId,
documentData,
recipient,
fields,
metadata,
isCompleted,
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const {
fullName,
email,
signature,
signatureValid,
setFullName,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
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.recipientId === recipient.id && !field.inserted),
fields.filter((field) => field.inserted),
];
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId();
const onNextFieldClick = () => {
validateFieldsInserted(fields);
setShowPendingFieldTooltip(true);
setIsExpanded(false);
};
const onCompleteClick = async () => {
try {
if (hasSignatureField && !signatureValid) {
return;
}
const valid = validateFieldsInserted(fields);
if (!valid) {
setShowPendingFieldTooltip(true);
return;
}
await completeDocumentWithToken({
documentId,
token,
});
if (window.parent) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token,
documentId,
recipientId: recipient.id,
},
},
'*',
);
}
setHasCompletedDocument(true);
} catch (err) {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-error',
data: null,
},
'*',
);
}
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to submit this document at this time. Please try again later.`,
),
variant: 'destructive',
});
}
};
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);
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setFullName(data.name);
}
// 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 (allowWhitelabelling) {
injectCss({
css: data.css,
cssVars: data.cssVars,
});
}
} catch (err) {
console.error(err);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
window.parent.postMessage(
{
action: 'document-ready',
data: null,
},
'*',
);
}
}, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (hasCompletedDocument) {
return (
<EmbedDocumentCompleted
name={fullName}
signature={{
id: 1,
fieldId: 1,
recipientId: 1,
created: new Date(),
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
typedSignature: signature?.startsWith('data:') ? null : signature,
}}
/>
);
}
return (
<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 />}
{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="embed--DocumentViewer flex-1">
<LazyPDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
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="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<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">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
<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="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>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="email">
<Trans>Email</Trans>
</Label>
<Input
type="email"
id="email"
className="bg-background mt-2"
value={email}
disabled
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</>
)}
</div>
</div>
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
<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={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
>
<Trans>Complete</Trans>
</Button>
)}
</div>
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
{/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} />
</div>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
</DocumentSigningRecipientProvider>
);
};

View File

@ -1,46 +0,0 @@
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

@ -1,28 +0,0 @@
import { type TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
export type AppBannerProps = {
banner: TSiteSettingsBannerSchema;
};
export const AppBanner = ({ banner }: AppBannerProps) => {
if (!banner.enabled) {
return null;
}
return (
<div className="mb-2" style={{ background: banner.data.bgColor }}>
<div
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
style={{ color: banner.data.textColor }}
>
<div className="flex items-center">
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
</div>
</div>
</div>
);
};
// Banner
// Custom Text
// Custom Text with Custom Icon

View File

@ -1,48 +0,0 @@
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

@ -1,404 +0,0 @@
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 { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
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';
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';
export type DocumentSigningFormProps = {
document: DocumentAndSender;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
};
export const DocumentSigningForm = ({
document,
recipient,
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 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.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
}, [fieldsRequiringValidation]);
const uninsertedRecipientFields = useMemo(() => {
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fieldsRequiringValidation);
};
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) {
return;
}
if (!isFieldsValid) {
return;
}
await completeDocument();
};
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) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
return (
<div
className={cn(
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
{
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
},
)}
>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
<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 ? (
<>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please mark as viewed to complete</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">
<div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</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" />
<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">
<Trans>Full Name</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
onChange={(value) => {
if (signatureValid) {
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div>
</fieldset>
</form>
</>
)}
</div>
</div>
</div>
);
};

View File

@ -1,245 +0,0 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} 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 { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field';
import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field';
import { DocumentSigningForm } from '~/components/general/document-signing/document-signing-form';
import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field';
import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field';
import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field';
import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field';
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
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: RecipientWithFields;
fields: Field[];
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
};
export const DocumentSigningPageView = ({
document,
recipient,
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;
let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`;
if (shouldUseTeamDetails) {
senderName = document.team?.name ?? '';
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"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<div className="max-w-[50ch]">
<span className="text-muted-foreground truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
<span className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to view this document
</Trans>
) : (
<Trans>has invited you to view this document</Trans>
),
)
.with(RecipientRole.SIGNER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
) : (
<Trans>has invited you to sign this document</Trans>
),
)
.with(RecipientRole.APPROVER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
) : (
<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>
<DocumentSigningRejectDialog document={document} token={recipient.token} />
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<DocumentSigningForm
document={document}
recipient={recipient}
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
.filter(
(field) =>
recipient.role !== RecipientRole.ASSISTANT ||
field.recipientId === selectedSigner?.id,
)
.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<DocumentSigningSignatureField
key={field.id}
field={field}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
<DocumentSigningInitialsField key={field.id} field={field} />
))
.with(FieldType.NAME, () => (
<DocumentSigningNameField key={field.id} field={field} />
))
.with(FieldType.DATE, () => (
<DocumentSigningDateField
key={field.id}
field={field}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<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} />;
})
.with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
};
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} />;
})
.with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
};
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} />;
})
.otherwise(() => null),
)}
</ElementVisible>
</div>
</DocumentSigningRecipientProvider>
);
};

View File

@ -1,67 +0,0 @@
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

@ -1,125 +0,0 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
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 defaultErrorCodeMap: ErrorCodeMap = {
404: {
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.`,
},
500: {
subHeading: msg`500 Internal Server Error`,
heading: msg`Oops! Something went wrong.`,
message: msg`An unexpected error occurred.`,
},
};
export const GenericErrorLayout = ({
children,
errorCode,
errorCodeMap = defaultErrorCodeMap,
primaryButton,
secondaryButton,
}: GenericErrorLayoutProps) => {
const navigate = useNavigate();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const { subHeading, heading, message } =
errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500];
return (
<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"
initial={{ opacity: 0 }}
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
>
<img
src={backgroundPattern}
alt="background pattern"
className="-ml-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%] dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</motion.div>
</div>
<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>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">{_(heading)}</h1>
<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"
onClick={() => {
void navigate(-1);
}}
>
<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>
</div>
</div>
</div>
);
};

View File

@ -1,18 +0,0 @@
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

@ -1,203 +0,0 @@
import { useMemo, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from './documents-table-action-button';
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
const { _, i18n } = useLingui();
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
id: 'sender',
header: _(msg`Sender`),
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.recipients}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
size: 140,
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<div className="relative">
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: team !== undefined,
}}
error={{
enable: isLoadingError || false,
}}
skeleton={{
enable: isLoading || false,
rows: 5,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell className="py-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-10 w-24 rounded" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)}
</div>
);
};
type DataTableTitleProps = {
row: DocumentsTableRow;
teamUrl?: string;
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id;
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
return match({
isOwner,
isRecipient,
isCurrentTeamDocument,
})
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
<Link
to={`${documentsPath}/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.with({ isRecipient: true }, () => (
<Link
to={`/sign/${recipient?.token}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.otherwise(() => (
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{row.title}
</span>
));
};

View File

@ -1,47 +0,0 @@
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,
});
}
}, []);
return null;
}
async function main() {
const locale = detect(fromHtmlTag('lang')) || 'en';
await dynamicActivate(locale);
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<I18nProvider i18n={i18n}>
<HydratedRouter />
</I18nProvider>
<PosthogInit />
</StrictMode>,
);
});
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
main();

View File

@ -1,82 +0,0 @@
import { i18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import { PassThrough } from 'node:stream';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n';
import { langCookie } from './storage/lang-cookie.server';
export const streamTimeout = 5_000;
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
if (!APP_I18N_OPTIONS.supportedLangs.includes(language)) {
language = extractLocaleData({ headers: request.headers }).lang;
}
await dynamicActivate(language);
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(
<I18nProvider i18n={i18n}>
<ServerRouter context={routerContext} url={request.url} />
</I18nProvider>,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}

View File

@ -1,186 +0,0 @@
import { useEffect } from 'react';
import Plausible from 'plausible-tracker';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
data,
isRouteErrorResponse,
useLoaderData,
useLocation,
} from 'react-router';
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 { 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';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
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 { 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 = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400..600&display=swap',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
},
{ rel: 'stylesheet', href: stylesheet },
];
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 = await getOptionalSession(request);
let teams: TGetTeamsResponse = [];
if (session.isAuthenticated) {
teams = await getTeams({ userId: session.user.id });
}
const { getTheme } = await themeSessionResolver(request);
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
lang = extractLocaleData({ headers: request.headers }).lang;
}
return data(
{
lang,
theme: getTheme(),
session: session.isAuthenticated
? {
user: session.user,
session: session.session,
teams,
}
: null,
publicEnv: createPublicEnv(),
},
{
headers: {
'Set-Cookie': await langCookie.serialize(lang),
},
},
);
}
export function Layout({ children }: { children: React.ReactNode }) {
const { theme } = useLoaderData<typeof loader>() || {};
const location = useLocation();
useEffect(() => {
if (env('NODE_ENV') === 'production') {
trackPageview();
}
}, [location.pathname]);
return (
<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" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="google" content="notranslate" />
<Meta />
<Links />
<meta name="google" content="notranslate" />
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
{/* 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 />
<RefreshOnFocus />
<script
dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
}}
/>
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error);
}
return <GenericErrorLayout errorCode={errorCode} />;
}

View File

@ -1,13 +0,0 @@
import { remixRoutesOptionAdapter } from '@react-router/remix-routes-option-adapter';
import { flatRoutes } from 'remix-flat-routes';
export default remixRoutesOptionAdapter((defineRoutes) => {
return flatRoutes('routes', defineRoutes, {
ignoredRouteFiles: ['**/.*'], // Ignore dot files (like .DS_Store)
//appDir: 'app',
//routeDir: 'routes',
//basePath: '/',
//paramPrefixChar: '$',
//routeRegex: /(([+][\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
});
});

View File

@ -1,65 +0,0 @@
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';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import type { Route } from './+types/_layout';
/**
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
*/
export const shouldRevalidate = () => false;
export const loader = async ({ request }: Route.LoaderArgs) => {
const requestHeaders = Object.fromEntries(request.headers.entries());
const session = await getOptionalSession(request);
if (!session.isAuthenticated) {
throw redirect('/signin');
}
const [limits, banner] = await Promise.all([
getLimits({ headers: requestHeaders }),
getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
),
]);
return {
banner,
limits,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams } = useSession();
const { banner, limits } = loaderData;
return (
<LimitsProvider initialValue={limits}>
<div id="portal-header"></div>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && <AppBanner banner={banner} />}
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</LimitsProvider>
);
}

View File

@ -1,9 +0,0 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/admin/stats');
}
export default function AdminPage() {
// Redirect page.
}

View File

@ -1,122 +0,0 @@
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 { 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';
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');
}
}
export default function AdminLayout() {
const { pathname } = useLocation();
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 md:mt-8 md:gap-8">
<div
className={cn(
'col-span-12 flex gap-x-2.5 gap-y-2 overflow-hidden overflow-x-auto md:col-span-3 md:flex md:flex-col',
)}
>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/stats') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
<Trans>Stats</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/users">
<Users className="mr-2 h-5 w-5" />
<Trans>Users</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/documents') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
<Trans>Documents</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
<Trans>Subscriptions</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/leaderboard">
<Trophy className="mr-2 h-5 w-5" />
<Trans>Leaderboard</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/banner') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
<Trans>Site Settings</Trans>
</Link>
</Button>
</div>
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -1,165 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { trpc } from '@documenso/trpc/react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import type { Route } from './+types/documents.$id';
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.id);
if (isNaN(id)) {
throw redirect('/admin/documents');
}
const document = await getEntireDocument({ id });
return { document };
}
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
const { document } = loaderData;
const { _, i18n } = useLingui();
const { toast } = useToast();
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Document resealed`),
});
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`Failed to reseal document`),
variant: 'destructive',
});
},
});
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
</div>
{document.deletedAt && (
<Badge size="large" variant="destructive">
<Trans>Deleted</Trans>
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
</div>
<div>
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">
<Trans>Admin Actions</Trans>
</h2>
<div className="mt-2 flex gap-x-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })}
>
<Trans>Reseal document</Trans>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
<Trans>
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link to={`/admin/users/${document.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">
<Trans>Recipients</Trans>
</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.recipients.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
className="rounded-lg border"
>
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<AdminDocumentRecipientItemTable recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<hr className="my-4" />
{document && <AdminDocumentDeleteDialog document={document} />}
</div>
);
}

View File

@ -1,66 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
import type { Route } from './+types/leaderboard';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
| 'asc'
| 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortBy = (
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
) as 'name' | 'createdAt' | 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
search,
page,
perPage,
sortBy,
sortOrder,
});
return {
signingVolume,
totalPages,
page,
perPage,
sortBy,
sortOrder,
};
}
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="mt-8">
<AdminLeaderboardTable
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
/>
</div>
</div>
);
}

View File

@ -1,224 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
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 type { z } from 'zod';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export async function loader() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return { banner };
}
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<SettingsHeader
title={_(msg`Site Settings`)}
subtitle={_(msg`Manage your site settings here`)}
/>
<div className="mt-8">
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to
display important information to your users.
</Trans>
</p>
<Form {...form}>
<form
className="mt-4 flex flex-col rounded-md"
onSubmit={form.handleSubmit(onBannerUpdate)}
>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset
className="flex flex-col gap-4 md:flex-row"
disabled={!enabled}
aria-disabled={!enabled}
>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
</div>
</div>
);
}

View File

@ -1,52 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
import type { Route } from './+types/users._index';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const [{ users, totalPages }, individualPrices] = await Promise.all([
findUsers({ username: search, email: search, page, perPage }),
getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return {
users,
totalPages,
individualPriceIds,
page,
perPage,
};
}
export default function AdminManageUsersPage({ loaderData }: Route.ComponentProps) {
const { users, totalPages, individualPriceIds, page, perPage } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Manage users</Trans>
</h2>
<AdminDashboardUsersTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div>
);
}

View File

@ -1,167 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Documents');
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
export default function DocumentsPage() {
const [searchParams] = useSearchParams();
const team = useOptionalCurrentTeam();
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
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');
}
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
};
useEffect(() => {
if (data?.stats) {
setStats(data.stats);
}
}, [data?.stats]);
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<DocumentUploadDropzone />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="text-4xl font-semibold">
<Trans>Documents</Trans>
</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
/>
) : (
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
)}
</div>
</div>
</div>
);
}

View File

@ -1,5 +0,0 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/settings/profile');
}

View File

@ -1,29 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Settings');
}
export default function SettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -1,32 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { ProfileForm } from '~/components/forms/profile';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Profile');
}
export default function SettingsProfile() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Profile`)}
subtitle={_(msg`Here you can edit your personal details.`)}
/>
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<hr className="my-4 max-w-xl" />
<AccountDeleteDialog className="max-w-xl" />
</div>
);
}

View File

@ -1,28 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityActivityTable } from '~/components/tables/settings-security-activity-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Security activity');
}
export default function SettingsSecurityActivity() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
/>
<div className="mt-4">
<SettingsSecurityActivityTable />
</div>
</div>
);
}

View File

@ -1,31 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { PasskeyCreateDialog } from '~/components/dialogs/passkey-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityPasskeyTable } from '~/components/tables/settings-security-passkey-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Manage passkeys');
}
export default function SettingsPasskeys() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Passkeys`)}
subtitle={_(msg`Manage your passkeys.`)}
hideDivider={true}
>
<PasskeyCreateDialog />
</SettingsHeader>
<div className="mt-4">
<SettingsSecurityPasskeyTable />
</div>
</div>
);
}

View File

@ -1,116 +0,0 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
const team = useOptionalCurrentTeam();
return (
<div>
<SettingsHeader
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
On this page, you can create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>{' '}
for more information.
</Trans>
}
/>
{team && team?.currentTeamMember.role !== TeamMemberRole.ADMIN ? (
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="warning"
>
<div>
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>You need to be an admin to manage API tokens.</Trans>
</AlertDescription>
</div>
</Alert>
) : (
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>
Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}
</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>
Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}
</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@ -1,9 +0,0 @@
import { redirect } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Route } from './+types/_index';
export function loader({ params }: Route.LoaderArgs) {
throw redirect(formatDocumentsPath(params.teamUrl));
}

View File

@ -1,104 +0,0 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Link, Outlet } from 'react-router';
import { TEAM_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { PortalComponent } from '~/components/general/portal';
import { TeamLayoutBillingBanner } from '~/components/general/teams/team-layout-billing-banner';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
export default function Layout({ params }: Route.ComponentProps) {
const { teams } = useSession();
const currentTeam = teams.find((team) => team.url === params.teamUrl);
const limits = useMemo(() => {
if (!currentTeam) {
return undefined;
}
if (
currentTeam?.subscription &&
currentTeam.subscription.status === SubscriptionStatus.INACTIVE
) {
return {
quota: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
remaining: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
};
}
return {
quota: TEAM_PLAN_LIMITS,
remaining: TEAM_PLAN_LIMITS,
};
}, [currentTeam?.subscription, currentTeam?.id]);
if (!currentTeam) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to="/settings/teams">
<Trans>View teams</Trans>
</Link>
</Button>
}
></GenericErrorLayout>
);
}
const trpcHeaders = {
'x-team-Id': currentTeam.id.toString(),
};
return (
<TeamProvider team={currentTeam}>
<LimitsProvider initialValue={limits} teamId={currentTeam.id}>
<TrpcProvider headers={trpcHeaders}>
{currentTeam?.subscription &&
currentTeam.subscription.status !== SubscriptionStatus.ACTIVE && (
<PortalComponent target="portal-header">
<TeamLayoutBillingBanner
subscriptionStatus={currentTeam.subscription.status}
teamId={currentTeam.id}
userRole={currentTeam.currentTeamMember.role}
/>
</PortalComponent>
)}
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</TrpcProvider>
</LimitsProvider>
</TeamProvider>
);
}

View File

@ -1,5 +0,0 @@
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.$id._index';
export { loader };
export default DocumentPage;

View File

@ -1,5 +0,0 @@
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.$id.edit';
export { loader };
export default DocumentEditPage;

View File

@ -1,5 +0,0 @@
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.$id.logs';
export { loader };
export default DocumentLogsPage;

View File

@ -1,5 +0,0 @@
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents._index';
export { meta };
export default DocumentsPage;

View File

@ -1,52 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Outlet, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings-nav-desktop';
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings._layout';
export function meta() {
return appMetaTags('Team Settings');
}
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw redirect(`/t/${params.teamUrl}`);
}
}
export async function clientLoader() {
// Do nothing, we only want the loader to run on SSR.
}
export default function TeamsSettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Team Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<TeamSettingsNavDesktop className="hidden md:col-span-3 md:flex" />
<TeamSettingsNavMobile className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link, useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { TeamMemberInviteDialog } from '~/components/dialogs/team-member-invite-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamSettingsMemberInvitesTable } from '~/components/tables/team-settings-member-invites-table';
import { TeamSettingsMembersDataTable } from '~/components/tables/team-settings-members-table';
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
title={_(msg`Members`)}
subtitle={_(msg`Manage the members or invite new members.`)}
>
<TeamMemberInviteDialog />
</SettingsHeader>
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={_(msg`Search`)}
/>
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="members" asChild>
<Link to={pathname ?? '/'}>
<Trans>Active</Trans>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
<Link to={`${pathname}?tab=invites`}>
<Trans>Pending</Trans>
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{currentTab === 'invites' ? (
<TeamSettingsMemberInvitesTable key="invites" />
) : (
<TeamSettingsMembersDataTable key="members" />
)}
</div>
</div>
);
}

View File

@ -1,50 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { TeamBrandingPreferencesForm } from '~/components/forms/team-branding-preferences-form';
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/settings.preferences';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Team Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
/>
<section>
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
<SettingsHeader
title={_(msg`Branding Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
className="mt-8"
/>
<section>
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
</div>
);
}

View File

@ -1,29 +0,0 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile';
import type { Route } from './+types/settings.public-profile';
// Todo: This can be optimized.
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
const { profile } = await getTeamPublicProfile({
userId: session.user.id,
teamId: team.id,
});
return {
profile,
};
}
// Todo: Test that the profile shows up correctly for teams.
export default PublicProfilePage;

View File

@ -1,3 +0,0 @@
import ApiTokensPage from '~/routes/_authenticated+/settings+/tokens';
export default ApiTokensPage;

View File

@ -1,5 +0,0 @@
import TemplatePage, { loader } from '~/routes/_authenticated+/templates.$id._index';
export { loader };
export default TemplatePage;

View File

@ -1,5 +0,0 @@
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates.$id.edit';
export { loader };
export default TemplateEditPage;

View File

@ -1,5 +0,0 @@
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates._index';
export { meta };
export default TemplatesPage;

View File

@ -1,94 +0,0 @@
import { useEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { Bird } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Templates');
}
export default function TemplatesPage() {
const [searchParams] = useSearchParams();
const team = useOptionalCurrentTeam();
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery({
page: page,
perPage: perPage,
});
// Refetch the templates when the team URL changes.
useEffect(() => {
void refetch();
}, [team?.url]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<div>
<TemplateCreateDialog templateRootPath={templateRootPath} teamId={team?.id} />
</div>
</div>
<div className="relative mt-5">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload one.
</Trans>
</p>
</div>
</div>
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
/>
)}
</div>
</div>
);
}

View File

@ -1,15 +0,0 @@
import { redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import type { Route } from './+types/_index';
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
if (isAuthenticated) {
throw redirect('/documents');
}
throw redirect('/signin');
}

View File

@ -1,122 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { PlusIcon } from 'lucide-react';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router';
import LogoIcon from '@documenso/assets/logo_icon.png';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { BrandingLogo } from '~/components/general/branding-logo';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Profile');
}
export default function PublicProfileLayout() {
const { sessionData } = useOptionalSession();
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div className="min-h-screen">
{sessionData ? (
<AuthenticatedHeader user={sessionData.user} teams={sessionData.teams} />
) : (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
)}
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:px-8">
<Link
to="/"
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<BrandingLogo className="hidden h-6 w-auto sm:block" />
<img
src={LogoIcon}
alt="Documenso Logo"
width={48}
height={48}
className="h-10 w-auto sm:hidden dark:invert"
/>
</Link>
<div className="flex flex-row items-center justify-center">
<p className="text-muted-foreground mr-4">
<span className="text-sm sm:hidden">
<Trans>Want your own public profile?</Trans>
</span>
<span className="hidden text-sm sm:block">
<Trans>Like to have your own public profile with agreements?</Trans>
</span>
</p>
<Button asChild variant="secondary">
<Link to="/signup">
<div className="hidden flex-row items-center sm:flex">
<PlusIcon className="mr-1 h-5 w-5" />
<Trans>Create now</Trans>
</div>
<span className="sm:hidden">
<Trans>Create</Trans>
</span>
</Link>
</Button>
</div>
</div>
</header>
)}
<main className="my-8 px-4 md:my-12 md:px-8">
<Outlet />
</main>
</div>
);
}
export function ErrorBoundary() {
const errorCodeMap = {
404: {
subHeading: msg`404 Profile not found`,
heading: msg`Oops! Something went wrong.`,
message: msg`The profile you are looking for could not be found.`,
},
};
return (
<GenericErrorLayout
errorCodeMap={errorCodeMap}
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to="/">
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
}
/>
);
}

View File

@ -1,47 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
/**
* A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not.
*
* Such as direct template access, or signing.
*/
export default function RecipientLayout() {
const { sessionData } = useOptionalSession();
return (
<div className="min-h-screen">
{sessionData?.user && (
<AuthenticatedHeader user={sessionData.user} teams={sessionData.teams} />
)}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">
<Outlet />
</main>
</div>
);
}
export function ErrorBoundary() {
return (
<GenericErrorLayout
secondaryButton={null}
primaryButton={
<Button asChild className="w-32">
<Link to="/">
<ChevronLeft className="mr-2 h-4 w-4" />
<Trans>Go Back</Trans>
</Link>
</Button>
}
/>
);
}

View File

@ -1,235 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningPageView } from '~/components/general/document-signing/document-signing-page-view';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
export async function loader({ params, request }: Route.LoaderArgs) {
const { requestMetadata } = getOptionalLoaderContext();
const { user } = await getOptionalSession(request);
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
requireAccessAuth: false,
}).catch(() => null),
getRecipientByToken({ token }).catch(() => null),
getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }),
]);
if (
!document ||
!document.documentData ||
!recipient ||
document.status === DocumentStatus.DRAFT
) {
throw new Response('Not Found', { status: 404 });
}
const recipientWithFields = { ...recipient, fields };
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
throw redirect(`/sign/${token}/waiting`);
}
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({
token,
})
: [];
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
let recipientHasAccount: boolean | null = null;
if (!isDocumentAccessValid) {
recipientHasAccount = await getUserByEmail({ email: recipient.email })
.then((user) => !!user)
.catch(() => false);
return superLoaderJson({
isDocumentAccessValid: false,
recipientEmail: recipient.email,
recipientHasAccount,
} as const);
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
const { documentMeta } = document;
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw redirect(`/sign/${token}/rejected`);
}
if (
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
throw redirect(documentMeta?.redirectUrl || `/sign/${token}/complete`);
}
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
return superLoaderJson({
isDocumentAccessValid: true,
document,
fields,
recipient,
recipientWithFields,
allRecipients,
completedFields,
recipientSignature,
isRecipientsTurn,
} as const);
}
export default function SigningPage() {
const data = useSuperLoaderData<typeof loader>();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
}
const {
document,
fields,
recipient,
completedFields,
recipientSignature,
isRecipientsTurn,
allRecipients,
recipientWithFields,
} = data;
if (document.deletedAt) {
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D
name={recipient.name}
signature={recipientSignature}
signingCelebrationImage={signingCelebration}
/>
<div className="relative mt-2 flex w-full flex-col items-center">
<div className="mt-8 flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">
<Trans>Document Cancelled</Trans>
</span>
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>
<span className="mt-1.5 block">"{document.title}"</span>
is no longer available to sign
</Trans>
</h2>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>This document has been cancelled by the owner.</Trans>
</p>
{user ? (
<Link to="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
<Trans>Go Back Home</Trans>
</Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
<Trans>
Want to send slick signing links like this one?{' '}
<Link
to="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</Trans>
</p>
)}
</div>
</div>
);
}
return (
<DocumentSigningProvider
email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined}
>
<DocumentSigningAuthProvider
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
>
<DocumentSigningPageView
recipient={recipientWithFields}
document={document}
fields={fields}
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
/>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}

View File

@ -1,43 +0,0 @@
/**
* https://posthog.com/docs/advanced/proxy/remix
*/
import type { Route } from './+types/ingest.$';
const API_HOST = 'eu.i.posthog.com';
const ASSET_HOST = 'eu-assets.i.posthog.com';
const posthogProxy = async (request: Request) => {
const url = new URL(request.url);
const hostname = url.pathname.startsWith('/ingest/static/') ? ASSET_HOST : API_HOST;
const newUrl = new URL(url);
newUrl.protocol = 'https';
newUrl.hostname = hostname;
newUrl.port = '443';
newUrl.pathname = newUrl.pathname.replace(/^\/ingest/, '');
const headers = new Headers(request.headers);
headers.set('host', hostname);
const response = await fetch(newUrl, {
method: request.method,
headers,
body: request.body,
// @ts-expect-error - Not really sure about this
duplex: 'half',
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
};
export async function loader({ request }: Route.LoaderArgs) {
return posthogProxy(request);
}
export async function action({ request }: Route.ActionArgs) {
return posthogProxy(request);
}

View File

@ -1,191 +0,0 @@
import satori from 'satori';
import sharp from 'sharp';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug';
import type { Route } from './+types/share.$slug.opengraph';
export const runtime = 'edge';
const CARD_OFFSET_TOP = 173;
const CARD_OFFSET_LEFT = 307;
const CARD_WIDTH = 590;
const CARD_HEIGHT = 337;
const IMAGE_SIZE = {
width: 1200,
height: 630,
};
export const loader = async ({ params }: Route.LoaderArgs) => {
const { slug } = params;
const baseUrl = NEXT_PUBLIC_WEBAPP_URL();
const [interSemiBold, interRegular, caveatRegular] = await Promise.all([
fetch(new URL(`${baseUrl}/fonts/inter-semibold.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL(`${baseUrl}/fonts/inter-regular.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL(`${baseUrl}/fonts/caveat-regular.ttf`, import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
]);
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({
slug,
});
if ('error' in recipientOrSender) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
const isRecipient = 'Signature' in recipientOrSender;
const signatureImage = match(recipientOrSender)
.with({ signatures: P.array(P._) }, (recipient) => {
return recipient.signatures?.[0]?.signatureImageAsBase64 || null;
})
.otherwise((sender) => {
return sender.signature || null;
});
const signatureName = match(recipientOrSender)
.with({ signatures: P.array(P._) }, (recipient) => {
return recipient.name || recipient.email;
})
.otherwise((sender) => {
return sender.name || sender.email;
});
// Generate SVG using Satori
const svg = await satori(
<div
style={{
display: 'flex',
height: '100%',
width: '100%',
backgroundColor: 'white',
position: 'relative',
}}
>
<img
src={`${baseUrl}/static/og-share-frame2.png`}
alt="og-share-frame"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}}
/>
{signatureImage ? (
<div
style={{
position: 'absolute',
padding: '24px 48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
top: CARD_OFFSET_TOP,
left: CARD_OFFSET_LEFT,
width: CARD_WIDTH,
height: CARD_HEIGHT,
}}
>
<img
src={signatureImage}
alt="signature"
style={{
opacity: 0.6,
height: '100%',
maxWidth: '100%',
}}
/>
</div>
) : (
<p
style={{
position: 'absolute',
padding: '24px 48px',
marginTop: '-8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
color: '#64748b',
fontFamily: 'Caveat',
fontSize: Math.max(Math.min((CARD_WIDTH * 1.5) / signatureName.length, 80), 36),
top: CARD_OFFSET_TOP,
left: CARD_OFFSET_LEFT,
width: CARD_WIDTH,
height: CARD_HEIGHT,
}}
>
{signatureName}
</p>
)}
<div
style={{
position: 'absolute',
display: 'flex',
width: '100%',
top: CARD_OFFSET_TOP - 78,
left: CARD_OFFSET_LEFT,
}}
>
<h2
style={{
fontSize: '20px',
color: '#828282',
fontFamily: 'Inter',
fontWeight: 700,
}}
>
{isRecipient ? 'Document Signed!' : 'Document Sent!'}
</h2>
</div>
</div>,
{
width: IMAGE_SIZE.width,
height: IMAGE_SIZE.height,
fonts: [
{
name: 'Caveat',
data: caveatRegular,
style: 'italic',
},
{
name: 'Inter',
data: interRegular,
weight: 400,
},
{
name: 'Inter',
data: interSemiBold,
weight: 600,
},
],
},
);
// Convert SVG to PNG using sharp
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
return new Response(pngBuffer, {
headers: {
'Content-Type': 'image/png',
'Content-Length': pngBuffer.length.toString(),
'Cache-Control': 'public, max-age=31536000, immutable',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
});
};

View File

@ -1,59 +0,0 @@
import { redirect } from 'react-router';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { Route } from './+types/share.$slug';
export function meta({ params: { slug } }: Route.MetaArgs) {
return [
{ title: 'Documenso - Share' },
{ description: 'I just signed a document in style with Documenso!' },
{
property: 'og:title',
content: 'Documenso - Join the open source signing revolution',
},
{
property: 'og:description',
content: 'I just signed with Documenso!',
},
{
property: 'og:type',
content: 'website',
},
{
property: 'og:image',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`,
},
{
name: 'twitter:site',
content: '@documenso',
},
{
name: 'twitter:card',
content: 'summary_large_image',
},
{
name: 'twitter:image',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`,
},
{
name: 'twitter:description',
content: 'I just signed with Documenso!',
},
];
}
export const loader = ({ request }: Route.LoaderArgs) => {
const userAgent = request.headers.get('User-Agent') ?? '';
if (/bot|facebookexternalhit|WhatsApp|google|bing|duckduckbot|MetaInspector/i.test(userAgent)) {
return null;
}
// Is hardcoded because this whole meta is hardcoded anyway for Documenso.
throw redirect('https://documenso.com');
};
export default function SharePage() {
return <div></div>;
}

View File

@ -1,74 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { SignInForm } from '~/components/forms/signin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/signin';
export function meta() {
return appMetaTags('Sign In');
}
export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
if (isAuthenticated) {
throw redirect('/documents');
}
return {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
<div className="border-border dark:bg-background z-10 rounded-xl border bg-neutral-100 p-6">
<h1 className="text-2xl font-semibold">
<Trans>Sign in to your account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Welcome back, we are lucky to have you.</Trans>
</p>
<hr className="-mx-6 my-4" />
<SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
/>
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
<Trans>
Don't have an account?{' '}
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
Sign up
</Link>
</Trans>
</p>
)}
</div>
</div>
);
}

View File

@ -1,42 +0,0 @@
import { redirect } from 'react-router';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { SignUpForm } from '~/components/forms/signup';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/signup';
export function meta() {
return appMetaTags('Sign Up');
}
export function loader() {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
throw redirect('/signin');
}
return {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
};
}
export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData;
return (
<SignUpForm
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
/>
);
}

View File

@ -1,191 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, CheckCircle2, Loader, XCircle } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { authClient } from '@documenso/auth/client';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { Route } from './+types/verify-email.$token';
export const loader = ({ params }: Route.LoaderArgs) => {
const { token } = params;
if (!token) {
throw redirect('/verify-email');
}
return {
token,
};
};
export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
const { token } = loaderData;
const { refreshSession } = useOptionalSession();
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [state, setState] = useState<keyof typeof EMAIL_VERIFICATION_STATE | null>(null);
const [isLoading, setIsLoading] = useState(false);
const verifyToken = async () => {
setIsLoading(true);
try {
const response = await authClient.emailPassword.verifyEmail({
token,
});
await refreshSession();
setState(response.state);
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
}
setIsLoading(false);
};
useEffect(() => {
void verifyToken();
}, []);
if (isLoading || state === null) {
return (
<div className="relative">
<Loader className="text-documenso h-8 w-8 animate-spin" />
</div>
);
}
return match(state)
.with(EMAIL_VERIFICATION_STATE.NOT_FOUND, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Something went wrong</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
We were unable to verify your email. If your email is not verified already, please
try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.EXPIRED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Your token has expired!</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
It seems that the provided token has expired. We've just sent you another token,
please check your email and try again.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.VERIFIED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Email Confirmed!</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
Your email has been successfully confirmed! You can now use all features of
Documenso.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Continue</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.with(EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED, () => (
<div className="w-screen max-w-lg px-4">
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">
<Trans>Email already confirmed</Trans>
</h2>
<p className="text-muted-foreground mt-4">
<Trans>
Your email has already been confirmed. You can now use all features of Documenso.
</Trans>
</p>
<Button className="mt-4" asChild>
<Link to="/">
<Trans>Go back home</Trans>
</Link>
</Button>
</div>
</div>
</div>
))
.exhaustive();
}

View File

@ -1,40 +0,0 @@
import { getAvatarImage } from '@documenso/lib/server-only/profile/get-avatar-image';
import type { Route } from './+types/avatar.$id';
export async function loader({ params }: Route.LoaderArgs) {
const { id } = params;
if (typeof id !== 'string') {
return Response.json(
{
status: 'error',
message: 'Missing id',
},
{ status: 400 },
);
}
const result = await getAvatarImage({ id });
if (!result) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
// res.setHeader('Content-Type', result.contentType);
// res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
// res.send(result.content);
return new Response(result.content, {
headers: {
'Content-Type': result.contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}

View File

@ -1,73 +0,0 @@
import sharp from 'sharp';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.team.$teamId';
export async function loader({ params }: Route.LoaderArgs) {
const teamId = Number(params.teamId);
if (teamId === 0 || Number.isNaN(teamId)) {
return Response.json(
{
status: 'error',
message: 'Invalid team ID',
},
{ status: 400 },
);
}
const settings = await prisma.teamGlobalSettings.findFirst({
where: {
teamId,
},
});
if (!settings || !settings.brandingEnabled) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
if (!settings.brandingLogo) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
const file = await getFile(JSON.parse(settings.brandingLogo)).catch(() => null);
if (!file) {
return Response.json(
{
status: 'error',
message: 'Not found',
},
{ status: 404 },
);
}
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
return new Response(img, {
headers: {
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
});
}

View File

@ -1,22 +0,0 @@
import { prisma } from '@documenso/prisma';
export async function loader() {
try {
await prisma.$queryRaw`SELECT 1`;
return Response.json({
status: 'ok',
message: 'All systems operational',
});
} catch (err) {
console.error(err);
return Response.json(
{
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 },
);
}
}

View File

@ -1,7 +0,0 @@
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
import type { Route } from './+types/limits';
export async function loader({ request }: Route.LoaderArgs) {
return limitsHandler(request);
}

View File

@ -1,19 +0,0 @@
import type { ActionFunctionArgs } from 'react-router';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { langCookie } from '~/storage/lang-cookie.server';
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const lang = formData.get('lang') || '';
if (!APP_I18N_OPTIONS.supportedLangs.find((l) => l === lang)) {
throw new Response('Unsupported language', { status: 400 });
}
return new Response('OK', {
status: 200,
headers: { 'Set-Cookie': await langCookie.serialize(lang) },
});
};

View File

@ -1,7 +0,0 @@
import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
import type { Route } from './+types/webhook.trigger';
export async function action({ request }: Route.ActionArgs) {
return await stripeWebhookHandler(request);
}

View File

@ -1,5 +0,0 @@
import { createThemeAction } from 'remix-themes';
import { themeSessionResolver } from '~/storage/theme-session.server';
export const action = createThemeAction(themeSessionResolver);

View File

@ -1,7 +0,0 @@
import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler';
import type { Route } from './+types/webhook.trigger';
export async function action({ request }: Route.ActionArgs) {
return handlerTriggerWebhooks(request);
}

View File

@ -1,74 +0,0 @@
import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
import { EmbedPaywall } from '~/components/embed/embed-paywall';
import type { Route } from './+types/_layout';
// Todo: (RR7) Test
export function headers({ loaderHeaders }: Route.HeadersArgs) {
const origin = loaderHeaders.get('Origin') ?? '*';
// Allow third parties to iframe the document.
return {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Origin': origin,
'Content-Security-Policy': `frame-ancestors ${origin}`,
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Content-Type-Options': 'nosniff',
};
}
export function loader() {
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
return {
isGoogleSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
};
}
export default function Layout() {
return <Outlet />;
}
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return (
<EmbedAuthenticationRequired
isGoogleSSOEnabled={isGoogleSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
email={error.data.email}
returnTo={error.data.returnTo}
/>
);
}
if (error.status === 403 && error.data.type === 'embed-paywall') {
return <EmbedPaywall />;
}
if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
return <EmbedDocumentWaitingForTurn />;
}
}
return <div>Not Found</div>;
}

View File

@ -1,155 +0,0 @@
import { data } from 'react-router';
import { match } from 'ts-pattern';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/direct.$url';
export async function loader({ params, request }: Route.LoaderArgs) {
if (!params.url) {
throw new Response('Not found', { status: 404 });
}
const token = params.url;
const template = await getTemplateByDirectLinkToken({
token,
}).catch(() => null);
// `template.directLink` is always available but we're doing this to
// satisfy the type checker.
if (!template || !template.directLink) {
throw new Response('Not found', { status: 404 });
}
// TODO: Make this more robust, we need to ensure the owner is either
// TODO: the member of a team that has an active subscription, is an early
// TODO: adopter or is an enterprise user.
if (IS_BILLING_ENABLED() && !template.teamId) {
throw data(
{
type: 'embed-paywall',
},
{
status: 403,
},
);
}
const { user } = await getOptionalSession(request);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: template.authOptions,
});
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
isDocumentPlatform(template),
isUserEnterprise({
userId: template.userId,
teamId: template.teamId ?? undefined,
}),
isUserCommunityPlan({
userId: template.userId,
teamId: template.teamId ?? undefined,
}),
]);
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
.exhaustive();
if (!isAccessAuthValid) {
throw data(
{
type: 'embed-authentication-required',
email: user?.email,
returnTo: `/embed/direct/${token}`,
},
{
status: 401,
},
);
}
const { directTemplateRecipientId } = template.directLink;
const recipient = template.recipients.find(
(recipient) => recipient.id === directTemplateRecipientId,
);
if (!recipient) {
throw new Response('Not found', { status: 404 });
}
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
const team = template.teamId
? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null)
: null;
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
return superLoaderJson({
token,
user,
template,
recipient,
fields,
hidePoweredBy,
isPlatformDocument,
isEnterpriseDocument,
isCommunityPlan,
});
}
export default function EmbedDirectTemplatePage() {
const {
token,
user,
template,
recipient,
fields,
hidePoweredBy,
isPlatformDocument,
isEnterpriseDocument,
isCommunityPlan,
} = useSuperLoaderData<typeof loader>();
return (
<DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
recipient={recipient}
user={user}
>
<DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage
token={token}
updatedAt={template.updatedAt}
documentData={template.templateDocumentData}
recipient={recipient}
fields={fields}
metadata={template.templateMeta}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
/>
</DocumentSigningRecipientProvider>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}

View File

@ -1,6 +0,0 @@
import { createCookie } from 'react-router';
export const langCookie = createCookie('lang', {
path: '/',
maxAge: 60 * 60 * 24 * 365 * 2,
});

View File

@ -1,18 +0,0 @@
import { createCookieSessionStorage } from 'react-router';
import { createThemeSessionResolver } from 'remix-themes';
import { getCookieDomain, useSecureCookies } from '@documenso/lib/constants/auth';
const themeSessionStorage = createCookieSessionStorage({
cookie: {
name: 'theme',
path: '/',
httpOnly: true,
sameSite: 'lax',
secrets: ['insecure-secret-do-not-care'],
secure: useSecureCookies,
domain: getCookieDomain(),
},
});
export const themeSessionResolver = createThemeSessionResolver(themeSessionStorage);

View File

@ -1,61 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
export const appMetaTags = (title?: string) => {
const description =
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.';
return [
{
title: title ? `${title} - Documenso` : 'Documenso',
},
{
name: 'description',
content: description,
},
{
name: 'keywords',
content:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
},
{
name: 'author',
content: 'Documenso, Inc.',
},
{
name: 'robots',
content: 'index, follow',
},
{
property: 'og:title',
content: 'Documenso - The Open Source DocuSign Alternative',
},
{
property: 'og:description',
content: description,
},
{
property: 'og:image',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/opengraph-image.jpg`,
},
{
property: 'og:type',
content: 'website',
},
{
name: 'twitter:card',
content: 'summary_large_image',
},
{
name: 'twitter:site',
content: '@documenso',
},
{
name: 'twitter:description',
content: description,
},
{
name: 'twitter:image',
content: `${NEXT_PUBLIC_WEBAPP_URL()}/opengraph-image.jpg`,
},
];
};

View File

@ -1,57 +0,0 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* https://github.com/kiliman/remix-superjson/
*/
import { useActionData, useLoaderData } from 'react-router';
import * as _superjson from 'superjson';
export type SuperJsonFunction = <Data extends unknown>(
data: Data,
init?: number | ResponseInit,
) => SuperTypedResponse<Data>;
export declare type SuperTypedResponse<T extends unknown = unknown> = Response & {
superjson(): Promise<T>;
};
type AppData = any;
type DataFunction = (...args: any[]) => unknown; // matches any function
type DataOrFunction = AppData | DataFunction;
export type UseDataFunctionReturn<T extends DataOrFunction> = T extends (
...args: any[]
) => infer Output
? Awaited<Output> extends SuperTypedResponse<infer U>
? U
: Awaited<ReturnType<T>>
: Awaited<T>;
export const superLoaderJson: SuperJsonFunction = (data, init = {}) => {
const responseInit = typeof init === 'number' ? { status: init } : init;
const headers = new Headers(responseInit.headers);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json; charset=utf-8');
}
return new Response(_superjson.stringify(data), {
...responseInit,
headers,
}) as SuperTypedResponse<typeof data>;
};
export function useSuperLoaderData<T = AppData>(): UseDataFunctionReturn<T> {
const data = useLoaderData();
return _superjson.deserialize(data);
}
export function useSuperActionData<T = AppData>(): UseDataFunctionReturn<T> | null {
const data = useActionData();
return data ? _superjson.deserialize(data) : null;
}

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