Compare commits
67 Commits
chore/webh
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
231f51bd1f | ||
|
|
a8de8368a2 | ||
|
|
7dd331addf | ||
|
|
c6743a7cec | ||
|
|
efbc097191 | ||
|
|
f1525991dc | ||
|
|
fb173e4d0e | ||
|
|
d422ffa873 | ||
|
|
55b7697316 | ||
|
|
67bbb6c6f4 | ||
|
|
63a4bab0fe | ||
|
|
9f17c1e48e | ||
|
|
91ae818213 | ||
|
|
a0ace803cf | ||
|
|
b3db3be8e9 | ||
|
|
44cdbeecb4 | ||
|
|
7214965c0c | ||
|
|
8d6bf91d12 | ||
|
|
fec078081b | ||
|
|
c646afcd97 | ||
|
|
63d990ce8d | ||
|
|
aa7d6b28a4 | ||
|
|
b990532633 | ||
|
|
65be37514f | ||
|
|
0df29fce36 | ||
|
|
ba5b7ce480 | ||
|
|
422770a8c7 | ||
|
|
083a706373 | ||
|
|
db326cb4a9 | ||
|
|
d664f571d6 | ||
|
|
7c38970ee8 | ||
|
|
e08d62c844 | ||
|
|
25bb6ffe77 | ||
|
|
e79d762710 | ||
|
|
d970976299 | ||
|
|
3dce814ab2 | ||
|
|
ad520bb032 | ||
|
|
596d30e2e5 | ||
|
|
6474b4a524 | ||
|
|
5b4db51051 | ||
|
|
cf58c80e31 | ||
|
|
11dbb8873e | ||
|
|
bc7907271b | ||
|
|
9b376d34cf | ||
|
|
deea99d865 | ||
|
|
3328074f51 | ||
|
|
5e69665e00 | ||
|
|
c1c7cfaf8b | ||
|
|
7e8955b89c | ||
|
|
cedd5e87b1 | ||
|
|
5255e8671f | ||
|
|
d4c1bad407 | ||
|
|
01dccb7916 | ||
|
|
483d7caef7 | ||
|
|
139bc265c7 | ||
|
|
991ce5ff46 | ||
|
|
7728c8641c | ||
|
|
50a41d0799 | ||
|
|
250381fec8 | ||
|
|
d2f3d24542 | ||
|
|
ec07092bf6 | ||
|
|
63e2ef0abf | ||
|
|
90ce52164c | ||
|
|
ac30654913 | ||
|
|
24f3ecd94f | ||
|
|
a319ea0f5e | ||
|
|
5ce2bae39d |
@@ -22,7 +22,6 @@ NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
|||||||
|
|
||||||
# [[URLS]]
|
# [[URLS]]
|
||||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
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)
|
# URL used by the web app to request itself (e.g. local background jobs)
|
||||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches: ['main', 'feat/rr7']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ tasks:
|
|||||||
cp .env.example .env &&
|
cp .env.example .env &&
|
||||||
set -a; source .env &&
|
set -a; source .env &&
|
||||||
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||||
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
|
||||||
command: npm run d
|
command: npm run d
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
echo "Copying pdf.js"
|
|
||||||
npm run copy:pdfjs --workspace apps/**
|
|
||||||
|
|
||||||
echo "Copying .well-known/ contents"
|
echo "Copying .well-known/ contents"
|
||||||
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ git clone https://github.com/<your-username>/documenso
|
|||||||
|
|
||||||
- NEXTAUTH_SECRET
|
- NEXTAUTH_SECRET
|
||||||
- NEXT_PUBLIC_WEBAPP_URL
|
- NEXT_PUBLIC_WEBAPP_URL
|
||||||
- NEXT_PUBLIC_MARKETING_URL
|
|
||||||
- NEXT_PRIVATE_DATABASE_URL
|
- NEXT_PRIVATE_DATABASE_URL
|
||||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
@@ -237,7 +236,6 @@ The following environment variables must be set:
|
|||||||
|
|
||||||
- `NEXTAUTH_SECRET`
|
- `NEXTAUTH_SECRET`
|
||||||
- `NEXT_PUBLIC_WEBAPP_URL`
|
- `NEXT_PUBLIC_WEBAPP_URL`
|
||||||
- `NEXT_PUBLIC_MARKETING_URL`
|
|
||||||
- `NEXT_PRIVATE_DATABASE_URL`
|
- `NEXT_PRIVATE_DATABASE_URL`
|
||||||
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
||||||
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3002",
|
"start": "next start -p 3002",
|
||||||
"lint:fix": "next lint --fix",
|
"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": {
|
"dependencies": {
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
|||||||
<EmbedDirectTemplate
|
<EmbedDirectTemplate
|
||||||
token={token}
|
token={token}
|
||||||
cssVars={{
|
cssVars={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ Set up the following environment variables in the `.env` file:
|
|||||||
```bash
|
```bash
|
||||||
NEXTAUTH_SECRET
|
NEXTAUTH_SECRET
|
||||||
NEXT_PUBLIC_WEBAPP_URL
|
NEXT_PUBLIC_WEBAPP_URL
|
||||||
NEXT_PUBLIC_MARKETING_URL
|
|
||||||
NEXT_PRIVATE_DATABASE_URL
|
NEXT_PRIVATE_DATABASE_URL
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL
|
NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
NEXT_PRIVATE_SMTP_FROM_NAME
|
NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
|
|||||||
@@ -13,35 +13,13 @@ Documenso uses the following stack to handle translations:
|
|||||||
|
|
||||||
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
|
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
|
## Quick guide
|
||||||
|
|
||||||
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
||||||
|
|
||||||
### HTML
|
### HTML
|
||||||
|
|
||||||
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
|
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/react/macro**.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<h1>
|
<h1>
|
||||||
@@ -64,8 +42,9 @@ For text that is broken into elements, but represent a whole sentence, you must
|
|||||||
### Constants outside of react components
|
### Constants outside of react components
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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.
|
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
|
||||||
export const CONSTANT_WITH_MSG = {
|
export const CONSTANT_WITH_MSG = {
|
||||||
@@ -98,31 +77,13 @@ Lingui provides a Plural component to make it easy. See full documentation [here
|
|||||||
|
|
||||||
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
|
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
|
```tsx
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
export const SomeComponent = () => {
|
export const SomeComponent = () => {
|
||||||
const { i18n } = setupI18nSSR();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
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>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -21,14 +21,25 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
|
|||||||
|
|
||||||
## API V2 - Beta
|
## API V2 - Beta
|
||||||
|
|
||||||
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
|
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
||||||
|
|
||||||
<Callout type="warning">
|
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.
|
||||||
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
|
|
||||||
|
Our new API V2 supports the following typed SDKs:
|
||||||
|
|
||||||
|
- [TypeScript](https://github.com/documenso/sdk-typescript)
|
||||||
|
- [Python](https://github.com/documenso/sdk-python)
|
||||||
|
- [Go](https://github.com/documenso/sdk-go)
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
For the staging API, please use the following base URL:
|
||||||
|
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||||
|
|
||||||
|
📖 [Documentation](https://documen.so/api-v2-docs)
|
||||||
|
|
||||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||||
|
|
||||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ Open the `.env` file and fill in the following variables:
|
|||||||
```bash
|
```bash
|
||||||
- NEXTAUTH_SECRET
|
- NEXTAUTH_SECRET
|
||||||
- NEXT_PUBLIC_WEBAPP_URL
|
- NEXT_PUBLIC_WEBAPP_URL
|
||||||
- NEXT_PUBLIC_MARKETING_URL
|
|
||||||
- NEXT_PRIVATE_DATABASE_URL
|
- NEXT_PRIVATE_DATABASE_URL
|
||||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint:fix": "next lint --fix",
|
"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": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
# Exit on error.
|
# Exit on error.
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|||||||
77
apps/remix/.bin/stripe-dev.sh
Executable file
77
apps/remix/.bin/stripe-dev.sh
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Set Error handling
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
|
WEB_APP_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
# Store the original directory
|
||||||
|
ORIGINAL_DIR=$(pwd)
|
||||||
|
|
||||||
|
# Set up trap to ensure we return to original directory
|
||||||
|
trap 'cd "$ORIGINAL_DIR"' EXIT
|
||||||
|
|
||||||
|
cd "$WEB_APP_DIR"
|
||||||
|
|
||||||
|
# Define env file paths
|
||||||
|
ENV_LOCAL_FILE="../../.env.local"
|
||||||
|
|
||||||
|
# Function to load environment variable from env files
|
||||||
|
load_env_var() {
|
||||||
|
local var_name=$1
|
||||||
|
local var_value=""
|
||||||
|
|
||||||
|
if [ -f "$ENV_LOCAL_FILE" ]; then
|
||||||
|
var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove quotes if present
|
||||||
|
var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/")
|
||||||
|
|
||||||
|
echo "$var_value"
|
||||||
|
}
|
||||||
|
|
||||||
|
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED")
|
||||||
|
|
||||||
|
# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true
|
||||||
|
if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then
|
||||||
|
echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files
|
||||||
|
NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY")
|
||||||
|
|
||||||
|
# Check if NEXT_PRIVATE_STRIPE_API_KEY exists
|
||||||
|
if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then
|
||||||
|
echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files."
|
||||||
|
echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Check if stripe CLI is installed
|
||||||
|
if ! command -v stripe &> /dev/null; then
|
||||||
|
echo "[ERROR]: Stripe CLI is not installed or not in PATH."
|
||||||
|
echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists
|
||||||
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET")
|
||||||
|
|
||||||
|
if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
|
||||||
|
echo "╔═════════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "║ Copy the webhook signing secret which will appear in the terminal ║"
|
||||||
|
echo "║ soon into the env file. ║"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "║ The webhook secret will start with whsec_... ║"
|
||||||
|
echo "║ ║"
|
||||||
|
echo "╚═════════════════════════════════════════════════════════════════════╝"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO]: Starting Stripe webhook listener..."
|
||||||
|
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -9,64 +14,208 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
|
export type NextSigner = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ConfirmationDialogProps = {
|
type ConfirmationDialogProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: (nextSigner?: NextSigner) => void;
|
||||||
hasUninsertedFields: boolean;
|
hasUninsertedFields: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: NextSigner;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export function AssistantConfirmationDialog({
|
export function AssistantConfirmationDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
hasUninsertedFields,
|
hasUninsertedFields,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: ConfirmationDialogProps) {
|
}: ConfirmationDialogProps) {
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: zodResolver(ZNextSignerFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const onOpenChange = () => {
|
const onOpenChange = () => {
|
||||||
if (isSubmitting) {
|
if (form.formState.isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsEditingNextSigner(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
|
if (allowDictateNextSigner && data.name && data.email) {
|
||||||
|
await onConfirm({
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await onConfirm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
disabled={form.formState.isSubmitting || isSubmitting}
|
||||||
|
className="border-none p-0"
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Complete Document</Trans>
|
<Trans>Complete Document</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
Are you sure you want to complete the document? This action cannot be undone. Please
|
Are you sure you want to complete the document? This action cannot be undone.
|
||||||
ensure that you have completed prefilling all relevant fields before proceeding.
|
Please ensure that you have completed prefilling all relevant fields before
|
||||||
|
proceeding.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<DocumentSigningDisclosure />
|
{allowDictateNextSigner && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mt-2"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Trans>Update Recipient</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditingNextSigner && (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's name"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
<Button
|
||||||
Cancel
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||||
onClick={onConfirm}
|
disabled={form.formState.isSubmitting || !isNextSignerValid}
|
||||||
disabled={isSubmitting}
|
loading={form.formState.isSubmitting}
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
{form.formState.isSubmitting ? (
|
||||||
|
<Trans>Submitting...</Trans>
|
||||||
|
) : hasUninsertedFields ? (
|
||||||
|
<Trans>Proceed</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
))
|
))
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
<Trans>By deleting this document, the following will occur:</Trans>
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
@@ -97,7 +97,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||||
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
<PDFViewer key={document?.id} documentData={documentData} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -30,22 +30,20 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type TokenDeleteDialogProps = {
|
export type TokenDeleteDialogProps = {
|
||||||
teamId?: number;
|
|
||||||
token: Pick<ApiToken, 'id' | 'name'>;
|
token: Pick<ApiToken, 'id' | 'name'>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TokenDeleteDialog({
|
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
|
||||||
teamId,
|
|
||||||
token,
|
|
||||||
onDelete,
|
|
||||||
children,
|
|
||||||
}: TokenDeleteDialogProps) {
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${token.name}`);
|
const deleteMessage = _(msg`delete ${token.name}`);
|
||||||
@@ -75,7 +73,7 @@ export default function TokenDeleteDialog({
|
|||||||
try {
|
try {
|
||||||
await deleteTokenMutation({
|
await deleteTokenMutation({
|
||||||
id: token.id,
|
id: token.id,
|
||||||
teamId,
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -230,14 +230,13 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-nowrap gap-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Create</Trans>
|
<Trans>Create</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
|||||||
export type EmbedAuthenticationRequiredProps = {
|
export type EmbedAuthenticationRequiredProps = {
|
||||||
email?: string;
|
email?: string;
|
||||||
returnTo: string;
|
returnTo: string;
|
||||||
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isOIDCSSOEnabled?: boolean;
|
||||||
|
oidcProviderLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedAuthenticationRequired = ({
|
export const EmbedAuthenticationRequired = ({
|
||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
|
// isGoogleSSOEnabled,
|
||||||
|
// isOIDCSSOEnabled,
|
||||||
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] w-full items-center justify-center">
|
<div className="flex min-h-[100dvh] w-full items-center justify-center">
|
||||||
@@ -28,7 +34,15 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<SignInForm className="mt-4" initialEmail={email} returnTo={returnTo} />
|
<SignInForm
|
||||||
|
// Embed currently not supported.
|
||||||
|
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
|
// oidcProviderLabel={oidcProviderLabel}
|
||||||
|
className="mt-4"
|
||||||
|
initialEmail={email}
|
||||||
|
returnTo={returnTo}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
|
|||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import {
|
||||||
|
isFieldUnsignedAndRequired,
|
||||||
|
isRequiredField,
|
||||||
|
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,7 +29,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
isPlatformOrEnterprise?: boolean;
|
allowWhiteLabelling?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedDirectTemplateClientPage = ({
|
export const EmbedDirectTemplateClientPage = ({
|
||||||
@@ -58,7 +62,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
isPlatformOrEnterprise = false,
|
allowWhiteLabelling = false,
|
||||||
}: EmbedDirectTemplateClientPageProps) => {
|
}: EmbedDirectTemplateClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -92,7 +96,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
localFields.filter((field) => !field.inserted),
|
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -110,7 +114,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
|
|
||||||
const newField: DirectTemplateLocalField = structuredClone({
|
const newField: DirectTemplateLocalField = structuredClone({
|
||||||
...field,
|
...field,
|
||||||
customText: payload.value,
|
customText: payload.value ?? '',
|
||||||
inserted: true,
|
inserted: true,
|
||||||
signedValue: payload,
|
signedValue: payload,
|
||||||
});
|
});
|
||||||
@@ -121,8 +125,10 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
signatureImageAsBase64:
|
||||||
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
||||||
|
typedSignature:
|
||||||
|
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +186,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(localFields);
|
validateFieldsInserted(pendingFields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -192,7 +198,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(localFields);
|
const valid = validateFieldsInserted(pendingFields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -205,12 +211,6 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
localFields.forEach((field) => {
|
|
||||||
if (!field.signedValue) {
|
|
||||||
throw new Error('Invalid configuration');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
documentId,
|
documentId,
|
||||||
token: documentToken,
|
token: documentToken,
|
||||||
@@ -221,13 +221,11 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
directRecipientEmail: email,
|
directRecipientEmail: email,
|
||||||
templateUpdatedAt: updatedAt,
|
templateUpdatedAt: updatedAt,
|
||||||
signedFieldValues: localFields.map((field) => {
|
signedFieldValues: localFields
|
||||||
if (!field.signedValue) {
|
.filter((field) => {
|
||||||
throw new Error('Invalid configuration');
|
return field.signedValue && (isRequiredField(field) || field.inserted);
|
||||||
}
|
})
|
||||||
|
.map((field) => field.signedValue!),
|
||||||
return field.signedValue;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
@@ -286,7 +284,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlatformOrEnterprise) {
|
if (allowWhiteLabelling) {
|
||||||
injectCss({
|
injectCss({
|
||||||
css: data.css,
|
css: data.css,
|
||||||
cssVars: data.cssVars,
|
cssVars: data.cssVars,
|
||||||
@@ -338,7 +336,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
@@ -347,7 +345,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] 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}
|
data-expanded={isExpanded || undefined}
|
||||||
>
|
>
|
||||||
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||||
@@ -415,6 +413,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasSignatureField && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">
|
<Label htmlFor="Signature">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
@@ -449,6 +448,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
33
apps/remix/app/components/embed/embed-document-rejected.tsx
Normal file
33
apps/remix/app/components/embed/embed-document-rejected.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export const EmbedDocumentRejected = () => {
|
||||||
|
return (
|
||||||
|
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<XCircle className="text-destructive h-10 w-10" />
|
||||||
|
|
||||||
|
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
|
<Trans>Document Rejected</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-destructive mt-4 flex items-center text-center text-sm">
|
||||||
|
<Trans>You have rejected this document</Trans>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||||
|
<Trans>
|
||||||
|
The document owner has been notified of your decision. They may contact you with further
|
||||||
|
instructions if necessary.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||||
|
<Trans>No further action is required from you at this time.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useState } from 'react';
|
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||||
import { type DocumentData, type Field, FieldType, RecipientRole } from '@prisma/client';
|
import {
|
||||||
|
type DocumentData,
|
||||||
|
type Field,
|
||||||
|
FieldType,
|
||||||
|
RecipientRole,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -18,7 +25,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -29,9 +36,11 @@ import { injectCss } from '~/utils/css-vars';
|
|||||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-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 { EmbedClientLoading } from './embed-client-loading';
|
||||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
import { EmbedDocumentFields } from './embed-document-fields';
|
import { EmbedDocumentFields } from './embed-document-fields';
|
||||||
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||||
|
|
||||||
export type EmbedSignDocumentClientPageProps = {
|
export type EmbedSignDocumentClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -42,7 +51,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
metadata?: DocumentMeta | TemplateMeta | null;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
hidePoweredBy?: boolean;
|
hidePoweredBy?: boolean;
|
||||||
isPlatformOrEnterprise?: boolean;
|
allowWhitelabelling?: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +64,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
metadata,
|
metadata,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
isPlatformOrEnterprise = false,
|
allowWhitelabelling = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
}: EmbedSignDocumentClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
@@ -74,6 +83,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||||
|
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
||||||
|
recipient.signingStatus === SigningStatus.REJECTED,
|
||||||
|
);
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||||
);
|
);
|
||||||
@@ -82,25 +94,34 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||||
|
|
||||||
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
|
|
||||||
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
||||||
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
||||||
|
|
||||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||||
|
|
||||||
const [pendingFields, _completedFields] = [
|
const [pendingFields, _completedFields] = [
|
||||||
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
|
fields.filter(
|
||||||
|
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||||
|
),
|
||||||
fields.filter((field) => field.inserted),
|
fields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
|
const fieldsRequiringValidation = useMemo(
|
||||||
|
() => fields.filter(isFieldUnsignedAndRequired),
|
||||||
|
[fields],
|
||||||
|
);
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(fields);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -112,7 +133,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(fields);
|
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -160,6 +181,25 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDocumentRejected = (reason: string) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-rejected',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasRejectedDocument(true);
|
||||||
|
};
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
@@ -173,12 +213,13 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
// Since a recipient can be provided a name we can lock it without requiring
|
// 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.
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
setIsNameLocked(!!data.lockName);
|
setIsNameLocked(!!data.lockName);
|
||||||
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||||
|
|
||||||
if (data.darkModeDisabled) {
|
if (data.darkModeDisabled) {
|
||||||
document.documentElement.classList.add('dark-mode-disabled');
|
document.documentElement.classList.add('dark-mode-disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlatformOrEnterprise) {
|
if (allowWhitelabelling) {
|
||||||
injectCss({
|
injectCss({
|
||||||
css: data.css,
|
css: data.css,
|
||||||
cssVars: data.cssVars,
|
cssVars: data.cssVars,
|
||||||
@@ -207,6 +248,10 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
}
|
}
|
||||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
}, [hasFinishedInit, hasDocumentLoaded]);
|
||||||
|
|
||||||
|
if (hasRejectedDocument) {
|
||||||
|
return <EmbedDocumentRejected />;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasCompletedDocument) {
|
if (hasCompletedDocument) {
|
||||||
return (
|
return (
|
||||||
<EmbedDocumentCompleted
|
<EmbedDocumentCompleted
|
||||||
@@ -228,10 +273,20 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<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 />}
|
{(!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">
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
@@ -240,7 +295,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
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"
|
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] 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}
|
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">
|
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||||
@@ -371,6 +426,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasSignatureField && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">
|
<Label htmlFor="Signature">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
@@ -405,6 +461,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -419,7 +476,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="col-start-2"
|
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||||
disabled={
|
disabled={
|
||||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useRevalidator } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -42,7 +42,7 @@ export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
|
|||||||
export const DisableAuthenticatorAppDialog = () => {
|
export const DisableAuthenticatorAppDialog = () => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
|
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
|
||||||
@@ -92,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => {
|
|||||||
onCloseTwoFactorDisableDialog();
|
onCloseTwoFactorDisableDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
await revalidate();
|
await refreshSession();
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Unable to disable two-factor authentication`),
|
title: _(msg`Unable to disable two-factor authentication`),
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useRevalidator } from 'react-router';
|
|
||||||
import { renderSVG } from 'uqr';
|
import { renderSVG } from 'uqr';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -48,7 +48,7 @@ export type EnableAuthenticatorAppDialogProps = {
|
|||||||
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
|
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
@@ -74,6 +74,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await authClient.twoFactor.setup();
|
const data = await authClient.twoFactor.setup();
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
setSetup2FAData(data);
|
setSetup2FAData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -92,6 +93,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
||||||
try {
|
try {
|
||||||
const data = await authClient.twoFactor.enable({ code: token });
|
const data = await authClient.twoFactor.enable({ code: token });
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
setRecoveryCodes(data.recoveryCodes);
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@@ -139,7 +141,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
|
|
||||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||||
setRecoveryCodes(null);
|
setRecoveryCodes(null);
|
||||||
void revalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export type AvatarImageFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
||||||
const { user } = useSession();
|
const { user, refreshSession } = useSession();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
@@ -103,13 +103,13 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
|||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Avatar Updated`),
|
title: _(msg`Avatar Updated`),
|
||||||
description: _(msg`Your avatar has been updated successfully.`),
|
description: _(msg`Your avatar has been updated successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
void revalidate();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Password</Trans>
|
<Trans>New Password</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput autoComplete="new-password" {...field} />
|
<PasswordInput autoComplete="new-password" {...field} />
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useRevalidator } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@@ -42,8 +41,7 @@ export type ProfileFormProps = {
|
|||||||
export const ProfileForm = ({ className }: ProfileFormProps) => {
|
export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user, refreshSession } = useSession();
|
||||||
const { revalidate } = useRevalidator();
|
|
||||||
|
|
||||||
const form = useForm<TProfileFormSchema>({
|
const form = useForm<TProfileFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
@@ -64,13 +62,13 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
signature,
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Profile updated`),
|
title: _(msg`Profile updated`),
|
||||||
description: _(msg`Your profile has been updated successfully.`),
|
description: _(msg`Your profile has been updated successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await revalidate();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type { User } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { UserProfileSkeleton } from '../general/user-profile-skeleton';
|
|
||||||
|
|
||||||
export const ZClaimPublicProfileFormSchema = z.object({
|
|
||||||
url: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.min(1, { message: 'Please enter a valid username.' })
|
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
|
||||||
message: 'Username can only container alphanumeric characters and dashes.',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
|
|
||||||
|
|
||||||
export type ClaimPublicProfileDialogFormProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
onClaimed?: () => void;
|
|
||||||
user: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimPublicProfileDialogForm = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onClaimed,
|
|
||||||
user,
|
|
||||||
}: ClaimPublicProfileDialogFormProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [claimed, setClaimed] = useState(false);
|
|
||||||
|
|
||||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
|
||||||
|
|
||||||
const form = useForm<TClaimPublicProfileFormSchema>({
|
|
||||||
values: {
|
|
||||||
url: user.url || '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZClaimPublicProfileFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
|
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updatePublicProfile({
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
setClaimed(true);
|
|
||||||
onClaimed?.();
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === 'PROFILE_URL_TAKEN') {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(msg`This username is already taken`),
|
|
||||||
});
|
|
||||||
} else if (error.code === 'PREMIUM_PROFILE_URL') {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
|
|
||||||
toast({
|
|
||||||
title: 'An error occurred',
|
|
||||||
description: error.userMessage ?? error.message,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to save your details. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent position="center" className="max-w-lg overflow-hidden">
|
|
||||||
{!claimed && (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="font-semi-bold text-center text-xl">
|
|
||||||
Introducing public profiles!
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="text-center">
|
|
||||||
Reserve your Documenso public profile username
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<img src={profileClaimTeaserImage} alt="profile claim teaser" />
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
className={cn(
|
|
||||||
'to-background -mt-32 flex w-full flex-col bg-gradient-to-b from-transparent to-15% pt-16 md:-mt-44',
|
|
||||||
)}
|
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
<fieldset className="-mt-6 flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Public profile username</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input type="text" className="mb-2 mt-2" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
|
|
||||||
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
|
|
||||||
{baseUrl.host}/u/{field.value || '<username>'}
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
|
||||||
<Button type="submit" loading={isSubmitting}>
|
|
||||||
Claim your username
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{claimed && (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="text-center">
|
|
||||||
We will let you know as soon as this features is launched
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<UserProfileSkeleton className="mt-4" user={user} rows={1} />
|
|
||||||
|
|
||||||
<div className="to-background -mt-12 flex w-full flex-col items-center bg-gradient-to-b from-transparent to-15% px-4 pt-8 md:-mt-12">
|
|
||||||
<Button className="w-full" onClick={() => onOpenChange?.(false)}>
|
|
||||||
Can't wait!
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -531,6 +531,27 @@ export const SignUpForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
<p className="text-muted-foreground mt-6 text-xs">
|
||||||
|
<Trans>
|
||||||
|
By proceeding, you agree to our{' '}
|
||||||
|
<Link
|
||||||
|
to="https://documen.so/terms"
|
||||||
|
target="_blank"
|
||||||
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Link
|
||||||
|
to="https://documen.so/privacy"
|
||||||
|
target="_blank"
|
||||||
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useTransition } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export const EXPIRATION_DATES = {
|
export const EXPIRATION_DATES = {
|
||||||
ONE_WEEK: msg`7 days`,
|
ONE_WEEK: msg`7 days`,
|
||||||
ONE_MONTH: msg`1 month`,
|
ONE_MONTH: msg`1 month`,
|
||||||
@@ -59,15 +61,14 @@ type NewlyCreatedToken = {
|
|||||||
|
|
||||||
export type ApiTokenFormProps = {
|
export type ApiTokenFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
teamId?: number;
|
|
||||||
tokens?: Pick<ApiToken, 'id'>[];
|
tokens?: Pick<ApiToken, 'id'>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => {
|
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||||
const [isTransitionPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
const [, copy] = useCopyToClipboard();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
|
|||||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
||||||
try {
|
try {
|
||||||
await createTokenMutation({
|
await createTokenMutation({
|
||||||
teamId,
|
teamId: team?.id,
|
||||||
tokenName,
|
tokenName,
|
||||||
expirationDate: noExpirationDate ? null : expirationDate,
|
expirationDate: noExpirationDate ? null : expirationDate,
|
||||||
});
|
});
|
||||||
@@ -238,7 +239,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="hidden md:inline-flex"
|
className="hidden md:inline-flex"
|
||||||
disabled={!form.formState.isDirty}
|
disabled={!form.formState.isDirty}
|
||||||
loading={form.formState.isSubmitting || isTransitionPending}
|
loading={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
<Trans>Create token</Trans>
|
<Trans>Create token</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -247,7 +248,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!form.formState.isDirty}
|
disabled={!form.formState.isDirty}
|
||||||
loading={form.formState.isSubmitting || isTransitionPending}
|
loading={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
<Trans>Create token</Trans>
|
<Trans>Create token</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const AppNavDesktop = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
|
||||||
onClick={() => setIsCommandMenuOpen(true)}
|
onClick={() => setIsCommandMenuOpen(true)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
138
apps/remix/app/components/general/billing-plans.tsx
Normal file
138
apps/remix/app/components/general/billing-plans.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type Interval = keyof PriceIntervals;
|
||||||
|
|
||||||
|
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
|
||||||
|
|
||||||
|
const FRIENDLY_INTERVALS: Record<Interval, MessageDescriptor> = {
|
||||||
|
day: msg`Daily`,
|
||||||
|
week: msg`Weekly`,
|
||||||
|
month: msg`Monthly`,
|
||||||
|
year: msg`Yearly`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
|
export type BillingPlansProps = {
|
||||||
|
prices: PriceIntervals;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const [interval, setInterval] = useState<Interval>('month');
|
||||||
|
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: createCheckoutSession } = trpc.profile.createCheckoutSession.useMutation();
|
||||||
|
|
||||||
|
const onSubscribeClick = async (priceId: string) => {
|
||||||
|
try {
|
||||||
|
setCheckoutSessionPriceId(priceId);
|
||||||
|
|
||||||
|
const url = await createCheckoutSession({ priceId });
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Unable to create session');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(url);
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`An error occurred while trying to create a checkout session.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCheckoutSessionPriceId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
|
||||||
|
<TabsList>
|
||||||
|
{INTERVALS.map(
|
||||||
|
(interval) =>
|
||||||
|
prices[interval].length > 0 && (
|
||||||
|
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
|
||||||
|
{_(FRIENDLY_INTERVALS[interval])}
|
||||||
|
</TabsTrigger>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{prices[interval].map((price) => (
|
||||||
|
<MotionCard
|
||||||
|
key={price.id}
|
||||||
|
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.3 } }}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
|
<CardTitle>{price.product.name}</CardTitle>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-2 text-lg font-medium">
|
||||||
|
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
|
||||||
|
<span className="text-xs">per {interval}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||||
|
{price.product.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{price.product.features && price.product.features.length > 0 && (
|
||||||
|
<div className="text-muted-foreground mt-4">
|
||||||
|
<div className="text-sm font-medium">Includes:</div>
|
||||||
|
|
||||||
|
<ul className="mt-1 divide-y text-sm">
|
||||||
|
{price.product.features.map((feature, index) => (
|
||||||
|
<li key={index} className="py-2">
|
||||||
|
{feature.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
disabled={checkoutSessionPriceId !== null}
|
||||||
|
loading={checkoutSessionPriceId === price.id}
|
||||||
|
onClick={() => void onSubscribeClick(price.id)}
|
||||||
|
>
|
||||||
|
<Trans>Subscribe</Trans>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</MotionCard>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
apps/remix/app/components/general/billing-portal-button.tsx
Normal file
48
apps/remix/app/components/general/billing-portal-button.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type BillingPortalButtonProps = {
|
||||||
|
buttonProps?: React.ComponentProps<typeof Button>;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BillingPortalButton = ({ buttonProps, children }: BillingPortalButtonProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: createBillingPortal, isPending } =
|
||||||
|
trpc.profile.createBillingPortal.useMutation({
|
||||||
|
onSuccess: (sessionUrl) => {
|
||||||
|
window.open(sessionUrl, '_blank');
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
let description = _(
|
||||||
|
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (err.message === 'CUSTOMER_NOT_FOUND') {
|
||||||
|
description = _(
|
||||||
|
msg`You do not currently have a customer record, this should not happen. Please contact support for assistance.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button {...buttonProps} onClick={async () => createBillingPortal()} loading={isPending}>
|
||||||
|
{children || <Trans>Manage Subscription</Trans>}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export const DirectTemplatePageView = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
key={template.id}
|
key={template.id}
|
||||||
documentData={template.templateDocumentData}
|
documentData={template.templateDocumentData}
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient, Signature } from '@prisma/client';
|
import type { Field, Recipient, Signature } from '@prisma/client';
|
||||||
@@ -91,7 +91,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
|
|
||||||
const tempField: DirectTemplateLocalField = {
|
const tempField: DirectTemplateLocalField = {
|
||||||
...field,
|
...field,
|
||||||
customText: value.value,
|
customText: value.value ?? '',
|
||||||
inserted: true,
|
inserted: true,
|
||||||
signedValue: value,
|
signedValue: value,
|
||||||
};
|
};
|
||||||
@@ -102,8 +102,8 @@ export const DirectTemplateSigningForm = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null,
|
signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null,
|
||||||
typedSignature: value.value.startsWith('data:') ? null : value.value,
|
typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +170,55 @@ export const DirectTemplateSigningForm = ({
|
|||||||
// Do not reset to false since we do a redirect.
|
// Do not reset to false since we do a redirect.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatedFields = [...localFields];
|
||||||
|
|
||||||
|
localFields.forEach((field) => {
|
||||||
|
const index = updatedFields.findIndex((f) => f.id === field.id);
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
match(field.type)
|
||||||
|
.with(FieldType.TEXT, () => {
|
||||||
|
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
|
||||||
|
|
||||||
|
if (meta?.success) {
|
||||||
|
value = meta.data.text ?? '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.with(FieldType.NUMBER, () => {
|
||||||
|
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
|
||||||
|
|
||||||
|
if (meta?.success) {
|
||||||
|
value = meta.data.value ?? '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.with(FieldType.DROPDOWN, () => {
|
||||||
|
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
|
||||||
|
|
||||||
|
if (meta?.success) {
|
||||||
|
value = meta.data.defaultValue ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const signedValue = {
|
||||||
|
token: directRecipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
|
||||||
|
updatedFields[index] = {
|
||||||
|
...field,
|
||||||
|
customText: value,
|
||||||
|
inserted: true,
|
||||||
|
signedValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalFields(updatedFields);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
||||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
const parsedFieldMeta = ZCheckboxFieldMeta.parse(
|
||||||
|
field.fieldMeta ?? {
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [{ id: 1, checked: false, value: '' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const values = parsedFieldMeta.values?.map((item) => ({
|
const values = parsedFieldMeta.values?.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -92,6 +97,10 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
|
if (!isLengthConditionMet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload: TSignFieldWithTokenMutationSchema = {
|
const payload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
@@ -189,18 +198,30 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
setCheckedValues(updatedValues);
|
setCheckedValues(updatedValues);
|
||||||
|
|
||||||
await removeSignedFieldWithToken({
|
const removePayload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
if (updatedValues.length > 0) {
|
if (onUnsignField) {
|
||||||
await signFieldWithToken({
|
await onUnsignField(removePayload);
|
||||||
|
} else {
|
||||||
|
await removeSignedFieldWithToken(removePayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedValues.length > 0 && shouldAutoSignField) {
|
||||||
|
const signPayload: TSignFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: toCheckboxValue(updatedValues),
|
value: toCheckboxValue(updatedValues),
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (onSignField) {
|
||||||
|
await onSignField(signPayload);
|
||||||
|
} else {
|
||||||
|
await signFieldWithToken(signPayload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field } from '@prisma/client';
|
import type { Field } from '@prisma/client';
|
||||||
import { RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -13,6 +17,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
@@ -21,10 +34,22 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
allowDictateNextSigner?: boolean;
|
||||||
|
defaultNextSigner?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZNextSignerFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
export const DocumentSigningCompleteDialog = ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
@@ -34,19 +59,54 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
allowDictateNextSigner = false,
|
||||||
|
defaultNextSigner,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
|
defaultValues: {
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (isSubmitting || !isComplete) {
|
if (form.formState.isSubmitting || !isComplete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
name: defaultNextSigner?.name ?? '',
|
||||||
|
email: defaultNextSigner?.email ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditingNextSigner(false);
|
||||||
setShowDialog(open);
|
setShowDialog(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
|
console.log('data', data);
|
||||||
|
console.log('form.formState.errors', form.formState.errors);
|
||||||
|
try {
|
||||||
|
if (allowDictateNextSigner && data.name && data.email) {
|
||||||
|
await onSignatureComplete({ name: data.name, email: data.email });
|
||||||
|
} else {
|
||||||
|
await onSignatureComplete();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing signature:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -58,21 +118,36 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
{match({ isComplete, role })
|
||||||
|
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||||
|
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||||
|
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||||
|
<Trans>Mark as viewed</Trans>
|
||||||
|
))
|
||||||
|
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="text-foreground text-xl font-semibold">
|
||||||
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
{match(role)
|
||||||
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||||
|
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{role === RecipientRole.VIEWER && (
|
{match(role)
|
||||||
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -85,8 +160,8 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<br /> Are you sure?
|
<br /> Are you sure?
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
)}
|
))
|
||||||
{role === RecipientRole.SIGNER && (
|
.with(RecipientRole.SIGNER, () => (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -99,8 +174,8 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<br /> Are you sure?
|
<br /> Are you sure?
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
)}
|
))
|
||||||
{role === RecipientRole.APPROVER && (
|
.with(RecipientRole.APPROVER, () => (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
<Trans>
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -113,37 +188,126 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<br /> Are you sure?
|
<br /> Are you sure?
|
||||||
</Trans>
|
</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<span>
|
||||||
|
<Trans>
|
||||||
|
<span className="inline-flex flex-wrap">
|
||||||
|
You are about to complete viewing "
|
||||||
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
|
</span>
|
||||||
|
<br /> Are you sure?
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowDictateNextSigner && (
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{!isEditingNextSigner && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The next recipient to sign this document will be{' '}
|
||||||
|
<span className="font-semibold">{form.watch('name')}</span> (
|
||||||
|
<span className="font-semibold">{form.watch('email')}</span>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mt-2"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Trans>Update Recipient</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditingNextSigner && (
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Name</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's name"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
className="mt-2"
|
||||||
|
placeholder="Enter the next signer's email"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="mt-4">
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => setShowDialog(false)}
|
||||||
setShowDialog(false);
|
disabled={form.formState.isSubmitting}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="submit"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!isComplete}
|
disabled={!isComplete || !isNextSignerValid}
|
||||||
loading={isSubmitting}
|
loading={form.formState.isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
|
||||||
>
|
>
|
||||||
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
{match(role)
|
||||||
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||||
|
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||||
|
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||||
|
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||||
|
.exhaustive()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -181,6 +181,23 @@ export const DocumentSigningFieldContainer = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||||
|
field.fieldMeta?.label && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||||
|
{
|
||||||
|
'bg-foreground/5 border-border border': !field.inserted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'bg-documenso-200 border-primary border': field.inserted,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.fieldMeta.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</FieldRootContainer>
|
</FieldRootContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useId, useMemo, useState } from 'react';
|
import { useId, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
@@ -25,10 +27,20 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
|
|||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
|
import {
|
||||||
|
AssistantConfirmationDialog,
|
||||||
|
type NextSigner,
|
||||||
|
} from '../../dialogs/assistant-confirmation-dialog';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
|
|
||||||
|
export const ZSigningFormSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').optional(),
|
||||||
|
email: z.string().email('Invalid email address').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSigningFormSchema = z.infer<typeof ZSigningFormSchema>;
|
||||||
|
|
||||||
export type DocumentSigningFormProps = {
|
export type DocumentSigningFormProps = {
|
||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
@@ -75,7 +87,9 @@ export const DocumentSigningForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, formState } = useForm();
|
const { handleSubmit, formState } = useForm<TSigningFormSchema>({
|
||||||
|
resolver: zodResolver(ZSigningFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
||||||
@@ -100,7 +114,8 @@ export const DocumentSigningForm = ({
|
|||||||
validateFieldsInserted(fieldsRequiringValidation);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async (data: TSigningFormSchema) => {
|
||||||
|
try {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
@@ -113,7 +128,22 @@ export const DocumentSigningForm = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await completeDocument();
|
const nextSigner =
|
||||||
|
data.email && data.name
|
||||||
|
? {
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error instanceof Error ? error.message : 'An error occurred while signing',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
const onAssistantFormSubmit = () => {
|
||||||
@@ -124,11 +154,11 @@ export const DocumentSigningForm = ({
|
|||||||
setIsConfirmationDialogOpen(true);
|
setIsConfirmationDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssistantConfirmDialogSubmit = async () => {
|
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
|
||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument();
|
await completeDocument(undefined, nextSigner);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -141,12 +171,18 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
const completeDocument = async (
|
||||||
await completeDocumentWithToken({
|
authOptions?: TRecipientActionAuth,
|
||||||
|
nextSigner?: { email: string; name: string },
|
||||||
|
) => {
|
||||||
|
const payload = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await completeDocumentWithToken(payload);
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
@@ -161,6 +197,31 @@ export const DocumentSigningForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nextRecipient = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!document.documentMeta?.signingOrder ||
|
||||||
|
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRecipients = allRecipients.sort((a, b) => {
|
||||||
|
// Sort by signingOrder first (nulls last), then by id
|
||||||
|
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
||||||
|
if (a.signingOrder === null) return 1;
|
||||||
|
if (b.signingOrder === null) return -1;
|
||||||
|
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
||||||
|
return a.signingOrder - b.signingOrder;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
||||||
|
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
||||||
|
? sortedRecipients[currentIndex + 1]
|
||||||
|
: undefined;
|
||||||
|
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||||
|
|
||||||
|
console.log('nextRecipient', nextRecipient);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -210,12 +271,19 @@ export const DocumentSigningForm = ({
|
|||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
disabled={!isRecipientsTurn}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,6 +374,14 @@ export const DocumentSigningForm = ({
|
|||||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||||
isSubmitting={isAssistantSubmitting}
|
isSubmitting={isAssistantSubmitting}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
@@ -313,7 +389,11 @@ export const DocumentSigningForm = ({
|
|||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||||
|
<Trans>Please review the document before approving.</Trans>
|
||||||
|
) : (
|
||||||
<Trans>Please review the document before signing.</Trans>
|
<Trans>Please review the document before signing.</Trans>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
<hr className="border-border mb-8 mt-4" />
|
||||||
@@ -337,6 +417,7 @@ export const DocumentSigningForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasSignatureField && (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">
|
<Label htmlFor="Signature">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
@@ -361,7 +442,7 @@ export const DocumentSigningForm = ({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
{!signatureValid && (
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<div className="text-destructive mt-2 text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Signature is too small. Please provide a more complete signature.
|
Signature is too small. Please provide a more complete signature.
|
||||||
@@ -369,9 +450,11 @@ export const DocumentSigningForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
@@ -384,16 +467,25 @@ export const DocumentSigningForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
role={recipient.role}
|
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
|
onSignatureComplete={async (nextSigner) => {
|
||||||
|
await completeDocument(undefined, nextSigner);
|
||||||
|
}}
|
||||||
|
role={recipient.role}
|
||||||
|
allowDictateNextSigner={
|
||||||
|
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||||
|
}
|
||||||
|
defaultNextSigner={
|
||||||
|
nextRecipient
|
||||||
|
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
|
|||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||||
@@ -40,9 +40,9 @@ import { DocumentReadOnlyFields } from '~/components/general/document/document-r
|
|||||||
|
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
||||||
|
|
||||||
export type SigningPageViewProps = {
|
export type DocumentSigningPageViewProps = {
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
|
document: DocumentAndSender;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: CompletedField[];
|
completedFields: CompletedField[];
|
||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
@@ -50,13 +50,13 @@ export type SigningPageViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageView = ({
|
||||||
document,
|
|
||||||
recipient,
|
recipient,
|
||||||
|
document,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: SigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||||
@@ -140,12 +140,7 @@ export const DocumentSigningPageView = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||||
key={documentData.id}
|
|
||||||
documentData={documentData}
|
|
||||||
document={document}
|
|
||||||
password={documentMeta?.password}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZRejectDocumentFormSchema = z.object({
|
const ZRejectDocumentFormSchema = z.object({
|
||||||
reason: z
|
reason: z.string().max(500, msg`Reason must be less than 500 characters`),
|
||||||
.string()
|
|
||||||
.min(5, msg`Please provide a reason`)
|
|
||||||
.max(500, msg`Reason must be less than 500 characters`),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||||
@@ -42,9 +39,14 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
|||||||
export interface DocumentSigningRejectDialogProps {
|
export interface DocumentSigningRejectDialogProps {
|
||||||
document: Pick<Document, 'id'>;
|
document: Pick<Document, 'id'>;
|
||||||
token: string;
|
token: string;
|
||||||
|
onRejected?: (reason: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentSigningRejectDialog({ document, token }: DocumentSigningRejectDialogProps) {
|
export function DocumentSigningRejectDialog({
|
||||||
|
document,
|
||||||
|
token,
|
||||||
|
onRejected,
|
||||||
|
}: DocumentSigningRejectDialogProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -75,9 +77,13 @@ export function DocumentSigningRejectDialog({ document, token }: DocumentSigning
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`/sign/${token}/rejected`);
|
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
|
if (onRejected) {
|
||||||
|
await onRejected(reason);
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${token}/rejected`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import type { DocumentStatus } from '@prisma/client';
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -76,7 +77,7 @@ export const DocumentCertificateDownloadButton = ({
|
|||||||
className={cn('w-full sm:w-auto', className)}
|
className={cn('w-full sm:w-auto', className)}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
disabled={!isDocumentCompleted(documentStatus)}
|
||||||
onClick={() => void onDownloadCertificatesClick()}
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
>
|
>
|
||||||
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
|||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export const DocumentEditForm = ({
|
|||||||
|
|
||||||
const { recipients, fields } = document;
|
const { recipients, fields } = document;
|
||||||
|
|
||||||
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
|
const { mutateAsync: updateDocumentSettings } = trpc.document.setSettingsForDocument.useMutation({
|
||||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
onSuccess: (newData) => {
|
onSuccess: (newData) => {
|
||||||
utils.document.getDocumentWithDetailsById.setData(
|
utils.document.getDocumentWithDetailsById.setData(
|
||||||
@@ -132,9 +132,6 @@ export const DocumentEditForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: setPasswordForDocument } =
|
|
||||||
trpc.document.setPasswordForDocument.useMutation();
|
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
settings: {
|
settings: {
|
||||||
title: msg`General`,
|
title: msg`General`,
|
||||||
@@ -179,7 +176,7 @@ export const DocumentEditForm = ({
|
|||||||
try {
|
try {
|
||||||
const { timezone, dateFormat, redirectUrl, language } = data.meta;
|
const { timezone, dateFormat, redirectUrl, language } = data.meta;
|
||||||
|
|
||||||
await updateDocument({
|
await updateDocumentSettings({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -216,6 +213,13 @@ export const DocumentEditForm = ({
|
|||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateDocumentSettings({
|
||||||
|
documentId: document.id,
|
||||||
|
meta: {
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
setRecipients({
|
setRecipients({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipients: data.signers.map((signer) => ({
|
recipients: data.signers.map((signer) => ({
|
||||||
@@ -245,7 +249,7 @@ export const DocumentEditForm = ({
|
|||||||
fields: data.fields,
|
fields: data.fields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateDocument({
|
await updateDocumentSettings({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
|
||||||
meta: {
|
meta: {
|
||||||
@@ -315,13 +319,6 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPasswordSubmit = async (password: string) => {
|
|
||||||
await setPasswordForDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,12 +337,10 @@ export const DocumentEditForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
key={document.documentData.id}
|
key={document.documentData.id}
|
||||||
documentData={document.documentData}
|
documentData={document.documentData}
|
||||||
document={document}
|
document={document}
|
||||||
password={document.documentMeta?.password}
|
|
||||||
onPasswordSubmit={onPasswordSubmit}
|
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -377,6 +372,7 @@ export const DocumentEditForm = ({
|
|||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
signingOrder={document.documentMeta?.signingOrder}
|
signingOrder={document.documentMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
isDocumentEnterprise={isDocumentEnterprise}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -32,7 +33,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
|
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = document.status === DocumentStatus.PENDING;
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(document);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@@ -63,7 +64,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
|||||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||||
const isPending = document.status === DocumentStatus.PENDING;
|
const isPending = document.status === DocumentStatus.PENDING;
|
||||||
const isDeleted = document.deletedAt !== null;
|
const isDeleted = document.deletedAt !== null;
|
||||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(document);
|
||||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
@@ -48,7 +49,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
<Trans>Recipients</Trans>
|
<Trans>Recipients</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{!isDocumentCompleted(document.status) && (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
@@ -36,6 +36,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
|
|||||||
icon: File,
|
icon: File,
|
||||||
color: 'text-yellow-500 dark:text-yellow-200',
|
color: 'text-yellow-500 dark:text-yellow-200',
|
||||||
},
|
},
|
||||||
|
REJECTED: {
|
||||||
|
label: msg`Rejected`,
|
||||||
|
labelExtended: msg`Document rejected`,
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-red-500 dark:text-red-300',
|
||||||
|
},
|
||||||
INBOX: {
|
INBOX: {
|
||||||
label: msg`Inbox`,
|
label: msg`Inbox`,
|
||||||
labelExtended: msg`Document inbox`,
|
labelExtended: msg`Document inbox`,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
void navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
await navigate(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export type GenericErrorLayoutProps = {
|
|||||||
|
|
||||||
export const defaultErrorCodeMap: ErrorCodeMap = {
|
export const defaultErrorCodeMap: ErrorCodeMap = {
|
||||||
404: {
|
404: {
|
||||||
subHeading: msg`404 Page not found`,
|
subHeading: msg`404 not found`,
|
||||||
heading: msg`Oops! Something went wrong.`,
|
heading: msg`Oops! Something went wrong.`,
|
||||||
message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`,
|
message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`,
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@ export const GenericErrorLayout = ({
|
|||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { subHeading, heading, message } =
|
const { subHeading, heading, message } =
|
||||||
errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500];
|
errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">
|
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import { useCallback, useEffect } from 'react';
|
|||||||
|
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not really used anymore, this causes random 500s when the user refreshes while this occurs.
|
||||||
|
*/
|
||||||
export const RefreshOnFocus = () => {
|
export const RefreshOnFocus = () => {
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate, state } = useRevalidator();
|
||||||
|
|
||||||
const onFocus = useCallback(() => {
|
const onFocus = useCallback(() => {
|
||||||
|
if (state === 'idle') {
|
||||||
void revalidate();
|
void revalidate();
|
||||||
|
}
|
||||||
}, [revalidate]);
|
}, [revalidate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import React from 'react';
|
|||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
export type SettingsHeaderProps = {
|
export type SettingsHeaderProps = {
|
||||||
title: string;
|
title: string | React.ReactNode;
|
||||||
subtitle: string;
|
subtitle: string | React.ReactNode;
|
||||||
hideDivider?: boolean;
|
hideDivider?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||||
@@ -161,6 +161,7 @@ export const TemplateEditForm = ({
|
|||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
meta: {
|
meta: {
|
||||||
signingOrder: data.signingOrder,
|
signingOrder: data.signingOrder,
|
||||||
|
allowDictateNextSigner: data.allowDictateNextSigner,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -236,7 +237,7 @@ export const TemplateEditForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
key={templateDocumentData.id}
|
key={templateDocumentData.id}
|
||||||
documentData={templateDocumentData}
|
documentData={templateDocumentData}
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
@@ -271,6 +272,7 @@ export const TemplateEditForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
signingOrder={template.templateMeta?.signingOrder}
|
signingOrder={template.templateMeta?.signingOrder}
|
||||||
|
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
|
||||||
templateDirectLink={template.directLink}
|
templateDirectLink={template.directLink}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
isEnterprise={isEnterprise}
|
isEnterprise={isEnterprise}
|
||||||
|
|||||||
@@ -1,43 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { WebhookTriggerEvents } from '@prisma/client';
|
import { WebhookTriggerEvents } from '@prisma/client';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||||
import { MultipleSelector } from '@documenso/ui/primitives/multiselect';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from '@documenso/ui/primitives/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/utils/truncate-title';
|
||||||
|
|
||||||
type WebhookMultiSelectComboboxProps = {
|
type WebhookMultiSelectComboboxProps = {
|
||||||
listValues: string[];
|
listValues: string[];
|
||||||
onChange: (_values: string[]) => void;
|
onChange: (_values: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerEvents = Object.values(WebhookTriggerEvents).map((value) => ({
|
|
||||||
value,
|
|
||||||
label: toFriendlyWebhookEventName(value),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const WebhookMultiSelectCombobox = ({
|
export const WebhookMultiSelectCombobox = ({
|
||||||
listValues,
|
listValues,
|
||||||
onChange,
|
onChange,
|
||||||
}: WebhookMultiSelectComboboxProps) => {
|
}: WebhookMultiSelectComboboxProps) => {
|
||||||
const handleOnChange = (options: { value: string; label: string }[]) => {
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
onChange(options.map((option) => option.value));
|
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||||
};
|
|
||||||
|
|
||||||
const mappedValues = listValues.map((value) => ({
|
const triggerEvents = Object.values(WebhookTriggerEvents);
|
||||||
value,
|
|
||||||
label: toFriendlyWebhookEventName(value),
|
useEffect(() => {
|
||||||
}));
|
setSelectedValues(listValues);
|
||||||
|
}, [listValues]);
|
||||||
|
|
||||||
|
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
|
||||||
|
|
||||||
|
const handleSelect = (currentValue: string) => {
|
||||||
|
let newSelectedValues;
|
||||||
|
|
||||||
|
if (selectedValues.includes(currentValue)) {
|
||||||
|
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
||||||
|
} else {
|
||||||
|
newSelectedValues = [...selectedValues, currentValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
onChange(newSelectedValues);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultipleSelector
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
commandProps={{
|
<PopoverTrigger asChild>
|
||||||
label: 'Select triggers',
|
<Button
|
||||||
}}
|
variant="outline"
|
||||||
defaultOptions={triggerEvents}
|
role="combobox"
|
||||||
value={mappedValues}
|
aria-expanded={isOpen}
|
||||||
onChange={handleOnChange}
|
className="w-[200px] justify-between"
|
||||||
placeholder="Select triggers"
|
>
|
||||||
hideClearAllButton
|
<Plural value={selectedValues.length} zero="Select values" other="# selected..." />
|
||||||
hidePlaceholderWhenSelected
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={truncateTitle(
|
||||||
|
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
|
||||||
|
15,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
<CommandEmpty>
|
||||||
|
<Trans>No value found.</Trans>
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allEvents.map((value: string, i: number) => (
|
||||||
|
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{toFriendlyWebhookEventName(value)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -37,7 +38,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Link } from 'react-router';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@@ -66,7 +67,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
// const isRecipient = !!recipient;
|
// const isRecipient = !!recipient;
|
||||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
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 { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
@@ -77,7 +77,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
|
|||||||
{
|
{
|
||||||
header: _(msg`Actions`),
|
header: _(msg`Actions`),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DocumentsTableActionButton row={row.original} />
|
<DocumentsTableActionButton row={row.original} />
|
||||||
<DocumentsTableActionDropdown row={row.original} />
|
<DocumentsTableActionDropdown row={row.original} />
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
import { StrictMode, startTransition } from 'react';
|
import { StrictMode, startTransition, useEffect } from 'react';
|
||||||
|
|
||||||
import { i18n } from '@lingui/core';
|
import { i18n } from '@lingui/core';
|
||||||
import { detect, fromHtmlTag } from '@lingui/detect-locale';
|
import { detect, fromHtmlTag } from '@lingui/detect-locale';
|
||||||
import { I18nProvider } from '@lingui/react';
|
import { I18nProvider } from '@lingui/react';
|
||||||
|
import posthog from 'posthog-js';
|
||||||
import { hydrateRoot } from 'react-dom/client';
|
import { hydrateRoot } from 'react-dom/client';
|
||||||
import { HydratedRouter } from 'react-router/dom';
|
import { HydratedRouter } from 'react-router/dom';
|
||||||
import { Theme, ThemeProvider } from 'remix-themes';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
|
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||||
|
|
||||||
async function main() {
|
function PosthogInit() {
|
||||||
const theme = match(document.documentElement.getAttribute('data-theme'))
|
const postHogConfig = extractPostHogConfig();
|
||||||
.with('dark', () => Theme.DARK)
|
|
||||||
.with('light', () => Theme.LIGHT)
|
|
||||||
.otherwise(() => null);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (postHogConfig) {
|
||||||
|
posthog.init(postHogConfig.key, {
|
||||||
|
api_host: postHogConfig.host,
|
||||||
|
capture_exceptions: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
const locale = detect(fromHtmlTag('lang')) || 'en';
|
const locale = detect(fromHtmlTag('lang')) || 'en';
|
||||||
|
|
||||||
await dynamicActivate(locale);
|
await dynamicActivate(locale);
|
||||||
@@ -25,10 +35,10 @@ async function main() {
|
|||||||
document,
|
document,
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
|
||||||
<HydratedRouter />
|
<HydratedRouter />
|
||||||
</ThemeProvider>
|
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|
||||||
|
<PosthogInit />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
|||||||
import { renderToPipeableStream } from 'react-dom/server';
|
import { renderToPipeableStream } from 'react-dom/server';
|
||||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||||
import { ServerRouter } from 'react-router';
|
import { ServerRouter } from 'react-router';
|
||||||
import { ThemeProvider } from 'remix-themes';
|
|
||||||
|
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n';
|
import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n';
|
||||||
|
|
||||||
import { langCookie } from './storage/lang-cookie.server';
|
import { langCookie } from './storage/lang-cookie.server';
|
||||||
import { themeSessionResolver } from './storage/theme-session.server';
|
|
||||||
|
|
||||||
export const streamTimeout = 5_000;
|
export const streamTimeout = 5_000;
|
||||||
|
|
||||||
@@ -32,10 +30,6 @@ export default async function handleRequest(
|
|||||||
|
|
||||||
await dynamicActivate(language);
|
await dynamicActivate(language);
|
||||||
|
|
||||||
const { getTheme } = await themeSessionResolver(request);
|
|
||||||
|
|
||||||
const theme = getTheme();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let shellRendered = false;
|
let shellRendered = false;
|
||||||
const userAgent = request.headers.get('user-agent');
|
const userAgent = request.headers.get('user-agent');
|
||||||
@@ -47,9 +41,7 @@ export default async function handleRequest(
|
|||||||
|
|
||||||
const { pipe, abort } = renderToPipeableStream(
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
|
||||||
<ServerRouter context={routerContext} url={request.url} />
|
<ServerRouter context={routerContext} url={request.url} />
|
||||||
</ThemeProvider>
|
|
||||||
</I18nProvider>,
|
</I18nProvider>,
|
||||||
{
|
{
|
||||||
[readyOption]() {
|
[readyOption]() {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import posthog from 'posthog-js';
|
|
||||||
import { useLocation, useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
|
||||||
|
|
||||||
export function PostHogPageview() {
|
|
||||||
const postHogConfig = extractPostHogConfig();
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// const { sessionData } = useOptionalSession();
|
|
||||||
// const user = sessionData?.user;
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && postHogConfig) {
|
|
||||||
posthog.init(postHogConfig.key, {
|
|
||||||
api_host: postHogConfig.host,
|
|
||||||
disable_session_recording: true,
|
|
||||||
// loaded: () => {
|
|
||||||
// if (user) {
|
|
||||||
// posthog.identify(user.email ?? user.id.toString());
|
|
||||||
// } else {
|
|
||||||
// posthog.reset();
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
custom_campaign_params: ['src'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!postHogConfig || !pathname) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = window.origin + pathname;
|
|
||||||
if (searchParams && searchParams.toString()) {
|
|
||||||
url = url + `?${searchParams.toString()}`;
|
|
||||||
}
|
|
||||||
posthog.capture('$pageview', {
|
|
||||||
$current_url: url,
|
|
||||||
});
|
|
||||||
}, [pathname, searchParams, postHogConfig]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Suspense, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import Plausible from 'plausible-tracker';
|
import Plausible from 'plausible-tracker';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useLoaderData,
|
useLoaderData,
|
||||||
useLocation,
|
useLocation,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import { PreventFlashOnWrongTheme, useTheme } from 'remix-themes';
|
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
||||||
@@ -27,8 +27,6 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
|||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import stylesheet from './app.css?url';
|
import stylesheet from './app.css?url';
|
||||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||||
import { RefreshOnFocus } from './components/general/refresh-on-focus';
|
|
||||||
import { PostHogPageview } from './providers/posthog';
|
|
||||||
import { langCookie } from './storage/lang-cookie.server';
|
import { langCookie } from './storage/lang-cookie.server';
|
||||||
import { themeSessionResolver } from './storage/theme-session.server';
|
import { themeSessionResolver } from './storage/theme-session.server';
|
||||||
import { appMetaTags } from './utils/meta';
|
import { appMetaTags } from './utils/meta';
|
||||||
@@ -106,9 +104,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { publicEnv, lang, session, ...data } = useLoaderData<typeof loader>() || {};
|
const { theme } = useLoaderData<typeof loader>() || {};
|
||||||
|
|
||||||
const [theme] = useTheme();
|
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -118,6 +114,18 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [location.pathname]);
|
}, [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 (
|
return (
|
||||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
|
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
|
||||||
<head>
|
<head>
|
||||||
@@ -133,10 +141,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
||||||
|
|
||||||
<Suspense>
|
|
||||||
<PostHogPageview />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||||
<script>0</script>
|
<script>0</script>
|
||||||
</head>
|
</head>
|
||||||
@@ -154,8 +158,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
|
|
||||||
<RefreshOnFocus />
|
|
||||||
|
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
||||||
@@ -171,9 +173,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
console.error('[RootErrorBoundary]', error);
|
|
||||||
|
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
||||||
|
|
||||||
|
if (errorCode !== 404) {
|
||||||
|
console.error('[RootErrorBoundary]', error);
|
||||||
|
}
|
||||||
|
|
||||||
return <GenericErrorLayout errorCode={errorCode} />;
|
return <GenericErrorLayout errorCode={errorCode} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ import type { Route } from './+types/_layout';
|
|||||||
*/
|
*/
|
||||||
export const shouldRevalidate = () => false;
|
export const shouldRevalidate = () => false;
|
||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const requestHeaders = Object.fromEntries(request.headers.entries());
|
const requestHeaders = Object.fromEntries(request.headers.entries());
|
||||||
|
|
||||||
const session = await getOptionalSession(request);
|
const session = await getOptionalSession(request);
|
||||||
|
|
||||||
if (!session.isAuthenticated) {
|
if (!session.isAuthenticated) {
|
||||||
return redirect('/signin');
|
throw redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [limits, banner] = await Promise.all([
|
const [limits, banner] = await Promise.all([
|
||||||
@@ -40,7 +40,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
banner,
|
banner,
|
||||||
limits,
|
limits,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function Layout({ loaderData }: Route.ComponentProps) {
|
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
const { user, teams } = useSession();
|
const { user, teams } = useSession();
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
loading={isResealDocumentLoading}
|
loading={isResealDocumentLoading}
|
||||||
disabled={document.recipients.some(
|
disabled={document.recipients.some(
|
||||||
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
(recipient) =>
|
||||||
|
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||||
|
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
||||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
import type { Route } from './+types/$id._index';
|
import type { Route } from './+types/documents.$id._index';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
const { user } = await getSession(request);
|
||||||
@@ -196,7 +196,7 @@ export default function DocumentPage() {
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -220,6 +220,9 @@ export default function DocumentPage() {
|
|||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with(DocumentStatus.COMPLETED, () => (
|
||||||
<Trans>This document has been signed by all recipients</Trans>
|
<Trans>This document has been signed by all recipients</Trans>
|
||||||
))
|
))
|
||||||
|
.with(DocumentStatus.REJECTED, () => (
|
||||||
|
<Trans>This document has been rejected by a recipient</Trans>
|
||||||
|
))
|
||||||
.with(DocumentStatus.DRAFT, () => (
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
<Trans>This document is currently a draft and has not been sent</Trans>
|
<Trans>This document is currently a draft and has not been sent</Trans>
|
||||||
))
|
))
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@@ -9,6 +9,7 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
|
|||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
@@ -16,7 +17,7 @@ import { DocumentStatus } from '~/components/general/document/document-status';
|
|||||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
import type { Route } from './+types/$id.edit';
|
import type { Route } from './+types/documents.$id.edit';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
const { user } = await getSession(request);
|
||||||
@@ -71,7 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === InternalDocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
throw redirect(`${documentRootPath}/${documentId}`);
|
throw redirect(`${documentRootPath}/${documentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from '~/components/general/document/document-status';
|
} from '~/components/general/document/document-status';
|
||||||
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
|
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
|
||||||
|
|
||||||
import type { Route } from './+types/$id.logs';
|
import type { Route } from './+types/documents.$id.logs';
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
const { user } = await getSession(request);
|
||||||
@@ -129,7 +129,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
<Trans>Document</Trans>
|
<Trans>Document</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
<div className="flex flex-col">
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<h1
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
@@ -137,7 +137,8 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
>
|
>
|
||||||
{document.title}
|
{document.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-col justify-between sm:flex-row">
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatusComponent
|
<DocumentStatusComponent
|
||||||
inheritColor
|
inheritColor
|
||||||
@@ -145,8 +146,6 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
<DocumentCertificateDownloadButton
|
<DocumentCertificateDownloadButton
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
@@ -157,13 +156,14 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
<DocumentAuditLogDownloadButton documentId={document.id} />
|
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
{documentInformation.map((info, i) => (
|
{documentInformation.map((info, i) => (
|
||||||
<div className="text-foreground text-sm" key={i}>
|
<div className="text-foreground text-sm" key={i}>
|
||||||
<h3 className="font-semibold">{_(info.description)}</h3>
|
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||||
<p className="text-muted-foreground">{info.value}</p>
|
<p className="text-muted-foreground truncate">{info.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ export default function DocumentsPage() {
|
|||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
[ExtendedDocumentStatus.PENDING]: 0,
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||||
|
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
[ExtendedDocumentStatus.INBOX]: 0,
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
[ExtendedDocumentStatus.ALL]: 0,
|
||||||
});
|
});
|
||||||
157
apps/remix/app/routes/_authenticated+/settings+/billing.tsx
Normal file
157
apps/remix/app/routes/_authenticated+/settings+/billing.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
import { redirect } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
|
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
||||||
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
|
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||||
|
|
||||||
|
import { BillingPlans } from '~/components/general/billing-plans';
|
||||||
|
import { BillingPortalButton } from '~/components/general/billing-portal-button';
|
||||||
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/billing';
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return appMetaTags('Billing');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getSession(request);
|
||||||
|
|
||||||
|
// Redirect if subscriptions are not enabled.
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
throw redirect('/settings/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.customerId) {
|
||||||
|
await getStripeCustomerByUser(user).then((result) => result.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
||||||
|
getSubscriptionsByUserId({ userId: user.id }),
|
||||||
|
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
|
||||||
|
getPrimaryAccountPlanPrices(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
|
||||||
|
|
||||||
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
|
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||||
|
primaryAccountPlanPriceIds.includes(priceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscription =
|
||||||
|
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||||
|
primaryAccountPlanSubscriptions[0];
|
||||||
|
|
||||||
|
if (subscription?.priceId) {
|
||||||
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMissingOrInactiveOrFreePlan =
|
||||||
|
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
prices,
|
||||||
|
subscription,
|
||||||
|
subscriptionProductName: subscriptionProduct?.name,
|
||||||
|
isMissingOrInactiveOrFreePlan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamsSettingBillingPage() {
|
||||||
|
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
|
||||||
|
useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-row items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">
|
||||||
|
<Trans>Billing</Trans>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
|
<p>
|
||||||
|
<Trans>
|
||||||
|
You are currently on the <span className="font-semibold">Free Plan</span>.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Todo: Translation */}
|
||||||
|
{!isMissingOrInactiveOrFreePlan &&
|
||||||
|
match(subscription.status)
|
||||||
|
.with('ACTIVE', () => (
|
||||||
|
<p>
|
||||||
|
{subscriptionProductName ? (
|
||||||
|
<span>
|
||||||
|
You are currently subscribed to{' '}
|
||||||
|
<span className="font-semibold">{subscriptionProductName}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>You currently have an active plan</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subscription.periodEnd && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
which is set to{' '}
|
||||||
|
{subscription.cancelAtPeriodEnd ? (
|
||||||
|
<span>
|
||||||
|
end on{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{i18n.date(subscription.periodEnd)}.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
automatically renew on{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{i18n.date(subscription.periodEnd)}.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.with('PAST_DUE', () => (
|
||||||
|
<p>
|
||||||
|
<Trans>
|
||||||
|
Your current plan is past due. Please update your payment information.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
|
<BillingPortalButton>
|
||||||
|
<Trans>Manage billing</Trans>
|
||||||
|
</BillingPortalButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,10 +21,10 @@ import { ManagePublicTemplateDialog } from '~/components/dialogs/public-profile-
|
|||||||
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
||||||
import { PublicProfileForm } from '~/components/forms/public-profile-form';
|
import { PublicProfileForm } from '~/components/forms/public-profile-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { SettingsPublicProfileTemplatesTable } from '~/components/tables/settings-public-profile-templates-table';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { SettingsPublicProfileTemplatesTable } from '../../../../components/tables/settings-public-profile-templates-table';
|
import type { Route } from './+types/public-profile';
|
||||||
import type { Route } from './+types/index';
|
|
||||||
|
|
||||||
type DirectTemplate = FindTemplateRow & {
|
type DirectTemplate = FindTemplateRow & {
|
||||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||||
@@ -60,7 +60,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { user } = useSession();
|
const { user, refreshSession } = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
||||||
@@ -96,6 +96,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await updateUserProfile(data);
|
await updateUserProfile(data);
|
||||||
|
|
||||||
|
// Need to refresh session because we're editing the user's profile.
|
||||||
|
await refreshSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.enabled === undefined && !isPublicProfileVisible) {
|
if (data.enabled === undefined && !isPublicProfileVisible) {
|
||||||
@@ -16,7 +16,7 @@ import { PasswordForm } from '~/components/forms/password';
|
|||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
import type { Route } from './+types';
|
import type { Route } from './+types/security._index';
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags('Security');
|
return appMetaTags('Security');
|
||||||
@@ -7,11 +7,10 @@ import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animat
|
|||||||
|
|
||||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
|
||||||
|
import { TeamInvitations } from '~/components/general/teams/team-invitations';
|
||||||
import { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table';
|
import { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table';
|
||||||
|
|
||||||
import { TeamEmailUsage } from './team-email-usage';
|
|
||||||
import { TeamInvitations } from './team-invitations';
|
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
|
|
||||||
import { ApiTokenForm } from '~/components/forms/token';
|
|
||||||
|
|
||||||
export default function ApiTokensPage() {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
|
|
||||||
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold">
|
|
||||||
<Trans>API Tokens</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
|
||||||
Also see our{' '}
|
|
||||||
<a
|
|
||||||
className="text-primary underline"
|
|
||||||
href={'https://docs.documenso.com/developers/public-api'}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
116
apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
Normal file
116
apps/remix/app/routes/_authenticated+/settings+/tokens.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import DocumentPage, { loader } from '~/routes/_authenticated+/documents+/$id._index';
|
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.$id._index';
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents+/$id.edit';
|
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.$id.edit';
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents+/$id.logs';
|
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.$id.logs';
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents+/_index';
|
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents._index';
|
||||||
|
|
||||||
export { meta };
|
export { meta };
|
||||||
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
|
|
||||||
import { ApiTokenForm } from '~/components/forms/token';
|
|
||||||
|
|
||||||
import type { Route } from './+types/tokens';
|
|
||||||
|
|
||||||
// Todo: This can be optimized.
|
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
|
||||||
const { user } = await getSession(request);
|
|
||||||
|
|
||||||
const team = await getTeamByUrl({
|
|
||||||
userId: user.id,
|
|
||||||
teamUrl: params.teamUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
team,
|
|
||||||
tokens,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ApiTokensPage({ loaderData }: Route.ComponentProps) {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
|
|
||||||
const { team, tokens } = loaderData;
|
|
||||||
|
|
||||||
if (!tokens) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold">
|
|
||||||
<Trans>API Tokens</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Something went wrong.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold">
|
|
||||||
<Trans>API Tokens</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
On this page, you can create new API tokens and manage the existing ones. <br />
|
|
||||||
You can view our swagger docs{' '}
|
|
||||||
<a
|
|
||||||
className="text-primary underline"
|
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<ApiTokenForm className="max-w-xl" teamId={team.id} tokens={tokens} />
|
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
|
||||||
|
|
||||||
<h4 className="text-xl font-medium">
|
|
||||||
<Trans>Your existing tokens</Trans>
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{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.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} teamId={team.id}>
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</TokenDeleteDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
|
|||||||
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
|
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
|
||||||
import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status';
|
import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status';
|
||||||
|
|
||||||
import type { Route } from './+types/_index';
|
import type { Route } from './+types/settings._index';
|
||||||
|
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
const { user } = await getSession(request);
|
||||||
@@ -9,7 +9,7 @@ import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings
|
|||||||
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
|
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/settings._layout';
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags('Team Settings');
|
return appMetaTags('Team Settings');
|
||||||
@@ -14,8 +14,9 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
|
import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
|
||||||
import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
|
import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
import type { Route } from './+types/billing';
|
import type { Route } from './+types/settings.billing';
|
||||||
|
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
@@ -31,16 +32,16 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||||||
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return superLoaderJson({
|
||||||
team,
|
team,
|
||||||
teamSubscription,
|
teamSubscription,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamsSettingBillingPage({ loaderData }: Route.ComponentProps) {
|
export default function TeamsSettingBillingPage() {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { team, teamSubscription } = loaderData;
|
const { team, teamSubscription } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
|
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import { TeamBrandingPreferencesForm } from '~/components/forms/team-branding-pr
|
|||||||
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
|
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
|
||||||
import type { Route } from './+types/preferences';
|
import type { Route } from './+types/settings.preferences';
|
||||||
|
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
const { user } = await getSession(request);
|
||||||
@@ -2,9 +2,9 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
|
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
|
||||||
|
|
||||||
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
|
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile';
|
||||||
|
|
||||||
import type { Route } from './+types/public-profile';
|
import type { Route } from './+types/settings.public-profile';
|
||||||
|
|
||||||
// Todo: This can be optimized.
|
// Todo: This can be optimized.
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import ApiTokensPage from '~/routes/_authenticated+/settings+/tokens';
|
||||||
|
|
||||||
|
export default ApiTokensPage;
|
||||||
@@ -28,7 +28,7 @@ import { SettingsHeader } from '~/components/general/settings-header';
|
|||||||
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
|
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import type { Route } from './+types/webhooks.$id';
|
import type { Route } from './+types/settings.webhooks.$id';
|
||||||
|
|
||||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import TemplatePage, { loader } from '~/routes/_authenticated+/templates+/$id._index';
|
import TemplatePage, { loader } from '~/routes/_authenticated+/templates.$id._index';
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user