Compare commits

..

1 Commits

Author SHA1 Message Date
75d7336763 feat: migrate nextjs to rr7 2025-03-17 14:20:41 +11:00
1009 changed files with 52633 additions and 42803 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import Link from 'next/link';
import { Button } from '../primitives/button';
import { Card, CardContent } from '../primitives/card';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
type CallToActionProps = {
className?: string;

View File

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

View File

@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
<EmbedDirectTemplate
token={token}
cssVars={{
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
}}
/>
```

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
return (

View File

@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
`}
// CSS Variables
cssVars={{
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
}}
// Dark Mode Control
darkModeDisabled={true}

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
return (

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
</script>

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
primary: '#0000FF',
background: '#F5F5F5',
radius: '8px',
};
</script>

View File

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

View File

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

View File

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

View File

@ -21,14 +21,25 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
## 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">
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
Our new API V2 supports the following typed SDKs:
- [TypeScript](https://github.com/documenso/sdk-typescript)
- [Python](https://github.com/documenso/sdk-python)
- [Go](https://github.com/documenso/sdk-go)
<Callout type="info">
For the staging API, please use the following base URL:
`https://stg-app.documenso.dev/api/v2-beta/`
</Callout>
🚀 [V2 Announcement](https://documen.so/sdk-blog)
📖 [Documentation](https://documen.so/api-v2-docs)
💬 [Leave Feedback](https://documen.so/sdk-feedback)
🔔 [Breaking Changes](https://documen.so/sdk-breaking)

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely

View File

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

37
apps/remix/.bin/build.sh Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env sh
# Exit on error.
set -eo pipefail
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.."
# Store the original directory
ORIGINAL_DIR=$(pwd)
# Set up trap to ensure we return to original directory
trap 'cd "$ORIGINAL_DIR"' EXIT
cd "$WEB_APP_DIR"
start_time=$(date +%s)
echo "[Build]: Extracting and compiling translations"
npm run translate --prefix ../../
echo "[Build]: Building app"
npm run build:app
echo "[Build]: Building server"
npm run build:server
# Copy over the entry point for the server.
cp server/main.js build/server/main.js
# Copy over all web.js translations
cp -r ../../packages/lib/translations build/server/hono/packages/lib/translations
# Time taken
end_time=$(date +%s)
echo "[Build]: Done in $((end_time - start_time)) seconds"

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

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

4
apps/remix/.dockerignore Normal file
View File

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

9
apps/remix/.gitignore vendored Normal file
View File

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

22
apps/remix/Dockerfile Normal file
View File

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

25
apps/remix/Dockerfile.bun Normal file
View File

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

View File

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

100
apps/remix/README.md Normal file
View File

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

24
apps/remix/app/app.css Normal file
View File

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

View File

@ -1,12 +1,11 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { signOut } from 'next-auth/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@documenso/prisma/client';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -23,12 +22,13 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteAccountDialogProps = {
export type AccountDeleteDialogProps = {
className?: string;
user: User;
};
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => {
const { user } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
@ -49,7 +49,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
duration: 5000,
});
return await signOut({ callbackUrl: '/' });
return await authClient.signOut();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
@ -118,7 +118,7 @@ export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProp
</DialogHeader>
{!hasTwoFactorAuthentication && (
<div className="mt-4">
<div>
<Label>
<Trans>
Please type{' '}

View File

@ -1,13 +1,11 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Document } from '@prisma/client';
import { useNavigate } from 'react-router';
import type { Document } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -23,15 +21,15 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type SuperDeleteDocumentDialogProps = {
export type AdminDocumentDeleteDialogProps = {
document: Document;
};
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const navigate = useNavigate();
const [reason, setReason] = useState('');
@ -52,7 +50,7 @@ export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialo
duration: 5000,
});
router.push('/admin/documents');
await navigate('/admin/documents');
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -1,15 +1,13 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -25,17 +23,15 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteUserDialogProps = {
export type AdminUserDeleteDialogProps = {
className?: string;
user: User;
};
export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => {
export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
@ -47,13 +43,13 @@ export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) =>
id: user.id,
});
await navigate('/admin/users');
toast({
title: _(msg`Account deleted`),
description: _(msg`The account has been deleted successfully.`),
duration: 5000,
});
router.push('/admin/users');
} catch (err) {
const error = AppError.parseError(err);

View File

@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -23,12 +22,15 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DisableUserDialogProps = {
export type AdminUserDisableDialogProps = {
className?: string;
userToDisable: User;
};
export const DisableUserDialog = ({ className, userToDisable }: DisableUserDialogProps) => {
export const AdminUserDisableDialog = ({
className,
userToDisable,
}: AdminUserDisableDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -23,12 +22,12 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnableUserDialogProps = {
export type AdminUserEnableDialogProps = {
className?: string;
userToEnable: User;
};
export const EnableUserDialog = ({ className, userToEnable }: EnableUserDialogProps) => {
export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -10,7 +10,7 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
type ConfirmationDialogProps = {
isOpen: boolean;
@ -51,7 +51,7 @@ export function AssistantConfirmationDialog({
</DialogHeader>
<div className="flex flex-col gap-4">
<SigningDisclosure />
<DocumentSigningDisclosure />
</div>
<DialogFooter className="mt-4">

View File

@ -1,13 +1,12 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -22,26 +21,26 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDocumentDialogProps = {
type DocumentDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export const DeleteDocumentDialog = ({
export const DocumentDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
status,
documentTitle,
canManageDocument,
}: DeleteDocumentDialogProps) => {
const router = useRouter();
}: DocumentDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
@ -52,8 +51,7 @@ export const DeleteDocumentDialog = ({
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({
onSuccess: () => {
router.refresh();
onSuccess: async () => {
void refreshLimits();
toast({
@ -62,8 +60,18 @@ export const DeleteDocumentDialog = ({
duration: 5000,
});
await onDelete?.();
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
@ -73,19 +81,6 @@ export const DeleteDocumentDialog = ({
}
}, [open, status]);
const onDelete = async () => {
try {
await deleteDocument({ documentId: id });
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
}
};
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
@ -151,7 +146,7 @@ export const DeleteDocumentDialog = ({
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
@ -194,7 +189,7 @@ export const DeleteDocumentDialog = ({
<Button
type="button"
loading={isPending}
onClick={onDelete}
onClick={() => void deleteDocument({ documentId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>

View File

@ -1,10 +1,9 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -14,30 +13,37 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateDocumentDialogProps = {
import { useOptionalCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
team?: Pick<Team, 'id' | 'url'>;
};
export const DuplicateDocumentDialog = ({
export const DocumentDuplicateDialog = ({
id,
open,
onOpenChange,
team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
}: DocumentDuplicateDialogProps) => {
const navigate = useNavigate();
const { toast } = useToast();
const { _ } = useLingui();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
const team = useOptionalCurrentTeam();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
{
documentId: id,
});
},
{
enabled: open === true,
},
);
const documentData = document?.documentData
? {
@ -50,15 +56,14 @@ export const DuplicateDocumentDialog = ({
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: ({ documentId }) => {
router.push(`${documentsPath}/${documentId}/edit`);
onSuccess: async ({ documentId }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${documentId}/edit`);
onOpenChange(false);
},
});
@ -92,7 +97,7 @@ export const DuplicateDocumentDialog = ({
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<LazyPDFViewer key={document?.id} documentData={documentData} />
<PDFViewer key={document?.id} documentData={documentData} />
</div>
)}

View File

@ -1,11 +1,10 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@ -26,30 +25,28 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type MoveDocumentDialogProps = {
type DocumentMoveDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
@ -97,9 +94,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">

View File

@ -1,19 +1,18 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Team } from '@prisma/client';
import { type Document, type Recipient, SigningStatus } from '@prisma/client';
import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Team } from '@documenso/prisma/client';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -37,16 +36,17 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
import { useOptionalCurrentTeam } from '~/providers/team';
import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
export type DocumentResendDialogProps = {
document: Document & {
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: Recipient[];
team?: Pick<Team, 'id' | 'url'>;
};
export const ZResendDocumentFormSchema = z.object({
@ -57,17 +57,15 @@ export const ZResendDocumentFormSchema = z.object({
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const ResendDocumentActionItem = ({
document,
recipients,
team,
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isOwner = document.userId === user.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =

View File

@ -1,10 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
@ -38,7 +37,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreatePasskeyDialogProps = {
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -51,7 +50,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
const parser = new UAParser();
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);

View File

@ -1,18 +1,17 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { Template, TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
import { TemplateType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import {
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,

View File

@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader, TagIcon } from 'lucide-react';
@ -20,18 +21,18 @@ import {
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreateTeamCheckoutDialogProps = {
export type TeamCheckoutCreateDialogProps = {
pendingTeamId: number | null;
onClose: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const MotionCard = motion(Card);
export const CreateTeamCheckoutDialog = ({
export const TeamCheckoutCreateDialog = ({
pendingTeamId,
onClose,
...props
}: CreateTeamCheckoutDialogProps) => {
}: TeamCheckoutCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -1,18 +1,17 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
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 { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
@ -37,7 +36,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreateTeamDialogProps = {
export type TeamCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -48,12 +47,12 @@ const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => {
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [open, setOpen] = useState(false);
@ -80,7 +79,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
setOpen(false);
if (response.paymentRequired) {
router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
return;
}
@ -120,7 +119,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open, setOpen, updateSearchParams]);
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();
@ -201,7 +200,7 @@ export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) =
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${WEBAPP_BASE_URL}/t/${field.value}`
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
<Trans>A unique URL to identify your team</Trans>
)}

View File

@ -1,13 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@ -34,14 +32,14 @@ import { Input } from '@documenso/ui/primitives/input';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTeamDialogProps = {
export type TeamDeleteDialogProps = {
teamId: number;
teamName: string;
trigger?: React.ReactNode;
};
export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => {
const router = useRouter();
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { _ } = useLingui();
@ -74,9 +72,9 @@ export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialog
duration: 5000,
});
setOpen(false);
await navigate('/settings/teams');
router.push('/settings/teams');
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);

View File

@ -1,15 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -36,7 +34,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AddTeamEmailDialogProps = {
export type TeamEmailAddDialogProps = {
teamId: number;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -48,13 +46,12 @@ const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pi
type TCreateTeamEmailFormSchema = z.infer<typeof ZCreateTeamEmailFormSchema>;
export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => {
const router = useRouter();
export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const form = useForm<TCreateTeamEmailFormSchema>({
resolver: zodResolver(ZCreateTeamEmailFormSchema),
@ -81,7 +78,7 @@ export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDi
duration: 5000,
});
router.refresh();
await revalidate();
setOpen(false);
} catch (err) {

View File

@ -1,15 +1,13 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Prisma } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -25,7 +23,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type RemoveTeamEmailDialogProps = {
export type TeamEmailDeleteDialogProps = {
trigger?: React.ReactNode;
teamName: string;
team: Prisma.TeamGetPayload<{
@ -42,13 +40,12 @@ export type RemoveTeamEmailDialogProps = {
}>;
};
export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEmailDialogProps) => {
export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const { revalidate } = useRevalidator();
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
@ -97,7 +94,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma
await deleteTeamEmailVerification({ teamId: team.id });
}
router.refresh();
await revalidate();
};
return (
@ -127,7 +124,7 @@ export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEma
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}

View File

@ -1,17 +1,15 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamEmail } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import type { TeamEmail } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -34,7 +32,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type UpdateTeamEmailDialogProps = {
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -45,17 +43,16 @@ const ZUpdateTeamEmailFormSchema = z.object({
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
export const UpdateTeamEmailDialog = ({
export const TeamEmailUpdateDialog = ({
teamEmail,
trigger,
...props
}: UpdateTeamEmailDialogProps) => {
const router = useRouter();
}: TeamEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const form = useForm<TUpdateTeamEmailFormSchema>({
resolver: zodResolver(ZUpdateTeamEmailFormSchema),
@ -81,7 +78,7 @@ export const UpdateTeamEmailDialog = ({
duration: 5000,
});
router.refresh();
await revalidate();
setOpen(false);
} catch (err) {

View File

@ -1,13 +1,12 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import type { TeamMemberRole } from '@documenso/prisma/client';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -23,7 +22,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type LeaveTeamDialogProps = {
export type TeamLeaveDialogProps = {
teamId: number;
teamName: string;
teamAvatarImageId?: string | null;
@ -31,13 +30,13 @@ export type LeaveTeamDialogProps = {
trigger?: React.ReactNode;
};
export const LeaveTeamDialog = ({
export const TeamLeaveDialog = ({
trigger,
teamId,
teamName,
teamAvatarImageId,
role,
}: LeaveTeamDialogProps) => {
}: TeamLeaveDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
@ -89,7 +88,7 @@ export const LeaveTeamDialog = ({
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${teamAvatarImageId}`}
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
avatarFallback={teamName.slice(0, 1).toUpperCase()}
primaryText={teamName}
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}

View File

@ -1,9 +1,8 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
@ -20,7 +19,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTeamMemberDialogProps = {
export type TeamMemberDeleteDialogProps = {
teamId: number;
teamName: string;
teamMemberId: number;
@ -29,14 +28,14 @@ export type DeleteTeamMemberDialogProps = {
trigger?: React.ReactNode;
};
export const DeleteTeamMemberDialog = ({
export const TeamMemberDeleteDialog = ({
trigger,
teamId,
teamName,
teamMemberId,
teamMemberName,
teamMemberEmail,
}: DeleteTeamMemberDialogProps) => {
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();

View File

@ -1,10 +1,10 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
@ -13,7 +13,6 @@ import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { cn } from '@documenso/ui/lib/utils';
@ -47,9 +46,9 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type InviteTeamMembersDialogProps = {
currentUserTeamRole: TeamMemberRole;
teamId: number;
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberInviteDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@ -96,12 +95,7 @@ const ZImportTeamMemberSchema = z.array(
}),
);
export const InviteTeamMembersDialog = ({
currentUserTeamRole,
teamId,
trigger,
...props
}: InviteTeamMembersDialogProps) => {
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
@ -109,6 +103,8 @@ export const InviteTeamMembersDialog = ({
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const form = useForm<TInviteTeamMembersFormSchema>({
resolver: zodResolver(ZInviteTeamMembersFormSchema),
defaultValues: {
@ -142,7 +138,7 @@ export const InviteTeamMembersDialog = ({
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
try {
await createTeamMemberInvites({
teamId,
teamId: team.id,
invitations,
});
@ -204,7 +200,7 @@ export const InviteTeamMembersDialog = ({
setInvitationType('INDIVIDUAL');
} catch (err) {
console.error(err.message);
console.error(err);
toast({
title: _(msg`Something went wrong`),
@ -325,11 +321,13 @@ export const InviteTeamMembersDialog = ({
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map(
(role) => (
<SelectItem key={role} value={role}>
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
),
)}
</SelectContent>
</Select>
</FormControl>

View File

@ -1,17 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { TeamMemberRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -40,7 +39,7 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type UpdateTeamMemberDialogProps = {
export type TeamMemberUpdateDialogProps = {
currentUserTeamRole: TeamMemberRole;
trigger?: React.ReactNode;
teamId: number;
@ -55,7 +54,7 @@ const ZUpdateTeamMemberFormSchema = z.object({
type ZUpdateTeamMemberSchema = z.infer<typeof ZUpdateTeamMemberFormSchema>;
export const UpdateTeamMemberDialog = ({
export const TeamMemberUpdateDialog = ({
currentUserTeamRole,
trigger,
teamId,
@ -63,7 +62,7 @@ export const UpdateTeamMemberDialog = ({
teamMemberName,
teamMemberRole,
...props
}: UpdateTeamMemberDialogProps) => {
}: TeamMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();

View File

@ -1,14 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@ -42,24 +40,24 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TransferTeamDialogProps = {
export type TeamTransferDialogProps = {
teamId: number;
teamName: string;
ownerUserId: number;
trigger?: React.ReactNode;
};
export const TransferTeamDialog = ({
export const TeamTransferDialog = ({
trigger,
teamId,
teamName,
ownerUserId,
}: TransferTeamDialogProps) => {
const router = useRouter();
}: TeamTransferDialogProps) => {
const [open, setOpen] = useState(false);
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutateAsync: requestTeamOwnershipTransfer } =
trpc.team.requestTeamOwnershipTransfer.useMutation();
@ -102,7 +100,7 @@ export const TransferTeamDialog = ({
clearPaymentMethods,
});
router.refresh();
await revalidate();
toast({
title: _(msg`Success`),

View File

@ -1,8 +1,7 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

View File

@ -1,15 +1,12 @@
'use client';
import { useState } from 'react';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { FilePlus, Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useNavigate } from 'react-router';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -26,21 +23,21 @@ import {
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
type NewTemplateDialogProps = {
type TemplateCreateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) => {
const router = useRouter();
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { data: session } = useSession();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (file: File) => {
@ -51,15 +48,11 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
setIsUploadingFile(true);
try {
const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
});
const response = await putPdfFile(file);
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId,
templateDocumentDataId: response.id,
});
toast({
@ -70,9 +63,9 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
duration: 5000,
});
setShowNewTemplateDialog(false);
setShowTemplateCreateDialog(false);
router.push(`${templateRootPath}/${id}/edit`);
await navigate(`${templateRootPath}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
@ -86,11 +79,11 @@ export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps)
return (
<Dialog
open={showNewTemplateDialog}
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
open={showTemplateCreateDialog}
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>New Template</Trans>
</Button>

View File

@ -1,7 +1,6 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -15,22 +14,25 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
type TemplateDeleteDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onDelete?: () => Promise<void> | void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
const router = useRouter();
export const TemplateDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
}: TemplateDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
onSuccess: async () => {
await onDelete?.();
toast({
title: _(msg`Template deleted`),

View File

@ -1,14 +1,12 @@
'use client';
import { useState } from 'react';
import React, { useState } from 'react';
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import { LinkIcon } from 'lucide-react';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
export type TemplateDirectLinkDialogWrapperProps = {
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };

View File

@ -1,11 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
type Recipient,
RecipientRole,
type Template,
type TemplateDirectLink,
} from '@prisma/client';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -14,12 +19,6 @@ import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import {
type Recipient,
RecipientRole,
type Template,
type TemplateDirectLink,
} from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -65,9 +64,9 @@ export const TemplateDirectLinkDialog = ({
const { toast } = useToast();
const { quota, remaining } = useLimits();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const [, copy] = useCopyToClipboard();
const router = useRouter();
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
const [token, setToken] = useState(template.directLink?.token ?? null);
@ -90,12 +89,12 @@ export const TemplateDirectLinkDialog = ({
isPending: isCreatingTemplateDirectLink,
reset: resetCreateTemplateDirectLink,
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: (data) => {
onSuccess: async (data) => {
await revalidate();
setToken(data.token);
setIsEnabled(data.enabled);
setCurrentStep('MANAGE');
router.refresh();
},
onError: () => {
setSelectedRecipientId(null);
@ -110,7 +109,9 @@ export const TemplateDirectLinkDialog = ({
const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => {
onSuccess: async (data) => {
await revalidate();
const enabledDescription = msg`Direct link signing has been enabled`;
const disabledDescription = msg`Direct link signing has been disabled`;
@ -133,7 +134,9 @@ export const TemplateDirectLinkDialog = ({
const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } =
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: () => {
onSuccess: async () => {
await revalidate();
onOpenChange(false);
setToken(null);
@ -143,7 +146,6 @@ export const TemplateDirectLinkDialog = ({
duration: 5000,
});
router.refresh();
setToken(null);
},
onError: () => {
@ -235,7 +237,7 @@ export const TemplateDirectLinkDialog = ({
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
href="/settings/billing"
to="/settings/billing"
>
Upgrade your account to continue!
</Link>
@ -436,7 +438,7 @@ export const TemplateDirectLinkDialog = ({
await toggleTemplateDirectLink({
templateId: template.id,
enabled: isEnabled,
}).catch((e) => null);
}).catch(() => null);
onOpenChange(false);
}}

View File

@ -1,7 +1,6 @@
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -15,28 +14,23 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
type TemplateDuplicateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
export const TemplateDuplicateDialog = ({
id,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
const router = useRouter();
}: TemplateDuplicateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),

View File

@ -1,13 +1,12 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@ -28,29 +27,46 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type MoveTemplateDialogProps = {
type TemplateMoveDialogProps = {
templateId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onMove?: ({
templateId,
teamUrl,
}: {
templateId: number;
teamUrl: string;
}) => Promise<void> | void;
};
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
const router = useRouter();
export const TemplateMoveDialog = ({
templateId,
open,
onOpenChange,
onMove,
}: TemplateMoveDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => {
router.refresh();
onSuccess: async () => {
const team = teams?.find((team) => team.id === selectedTeamId);
if (team) {
await onMove?.({ templateId, teamUrl: team.url });
}
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (err) => {
@ -73,7 +89,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
},
});
const onMove = async () => {
const handleOnMove = async () => {
if (!selectedTeamId) {
return;
}
@ -108,9 +124,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
)}
<AvatarFallback className="text-sm text-gray-400">
@ -130,7 +144,11 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
<Button
onClick={handleOnMove}
loading={isPending}
disabled={!selectedTeamId || isPending}
>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>

View File

@ -1,14 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
@ -18,8 +18,6 @@ import {
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -94,7 +92,7 @@ const ZAddRecipientsForNewDocumentSchema = z
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type UseTemplateDialogProps = {
export type TemplateUseDialogProps = {
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
@ -103,19 +101,19 @@ export type UseTemplateDialogProps = {
trigger?: React.ReactNode;
};
export function UseTemplateDialog({
export function TemplateUseDialog({
recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
templateId,
templateSigningOrder,
trigger,
}: UseTemplateDialogProps) {
const router = useRouter();
}: TemplateUseDialogProps) {
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const form = useForm<TAddRecipientsForNewDocumentSchema>({
@ -179,7 +177,7 @@ export function UseTemplateDialog({
documentPath += '?action=view-signing-links';
}
router.push(documentPath);
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);

View File

@ -1,16 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { ApiToken } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -33,35 +30,31 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTokenDialogProps = {
teamId?: number;
import { useOptionalCurrentTeam } from '~/providers/team';
export type TokenDeleteDialogProps = {
token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void;
children?: React.ReactNode;
};
export default function DeleteTokenDialog({
teamId,
token,
onDelete,
children,
}: DeleteTokenDialogProps) {
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const team = useOptionalCurrentTeam();
const [isOpen, setIsOpen] = useState(false);
const deleteMessage = _(msg`delete ${token.name}`);
const ZDeleteTokenDialogSchema = z.object({
const ZTokenDeleteDialogSchema = z.object({
tokenName: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
}),
});
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZTokenDeleteDialogSchema>;
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
onSuccess() {
@ -70,7 +63,7 @@ export default function DeleteTokenDialog({
});
const form = useForm<TDeleteTokenByIdMutationSchema>({
resolver: zodResolver(ZDeleteTokenDialogSchema),
resolver: zodResolver(ZTokenDeleteDialogSchema),
values: {
tokenName: '',
},
@ -80,7 +73,7 @@ export default function DeleteTokenDialog({
try {
await deleteTokenMutation({
id: token.id,
teamId,
teamId: team?.id,
});
toast({
@ -90,8 +83,6 @@ export default function DeleteTokenDialog({
});
setIsOpen(false);
router.refresh();
} catch (error) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -1,12 +1,9 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
@ -39,22 +36,20 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
export type CreateWebhookDialogProps = {
export type WebhookCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
@ -94,8 +89,6 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
});
form.reset();
router.refresh();
} catch (err) {
toast({
title: _(msg`Error`),
@ -191,7 +184,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
@ -237,14 +230,13 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</div>
</DialogFooter>
</fieldset>
</form>

View File

@ -1,16 +1,13 @@
'use effect';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { Webhook } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -35,18 +32,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DeleteWebhookDialogProps = {
export type WebhookDeleteDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
onDelete?: () => void;
children: React.ReactNode;
};
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
@ -81,8 +76,6 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr
});
setOpen(false);
router.refresh();
} catch (error) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -0,0 +1,49 @@
import { Trans } from '@lingui/react/macro';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { SignInForm } from '~/components/forms/signin';
import { BrandingLogo } from '~/components/general/branding-logo';
export type EmbedAuthenticationRequiredProps = {
email?: string;
returnTo: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
};
export const EmbedAuthenticationRequired = ({
email,
returnTo,
// isGoogleSSOEnabled,
// isOIDCSSOEnabled,
// oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => {
return (
<div className="flex min-h-[100dvh] w-full items-center justify-center">
<div className="flex w-full max-w-md flex-col">
<BrandingLogo className="h-8" />
<Alert className="mt-8" variant="warning">
<AlertDescription>
<Trans>
To view this document you need to be signed into your account, please sign in to
continue.
</Trans>
</AlertDescription>
</Alert>
<SignInForm
// Embed currently not supported.
// isGoogleSSOEnabled={isGoogleSSOEnabled}
// isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel}
className="mt-4"
initialEmail={email}
returnTo={returnTo}
/>
</div>
</div>
);
};

View File

@ -1,21 +1,23 @@
'use client';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@ -27,19 +29,19 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { DirectTemplateLocalField } from '~/app/(recipient)/d/[token]/sign-direct-template';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { Logo } from '~/components/branding/logo';
import { BrandingLogo } from '~/components/general/branding-logo';
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
import { injectCss } from '~/utils/css-vars';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
import { injectCss } from '../../util';
import { ZDirectTemplateEmbedDataSchema } from './schema';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
export type EmbedDirectTemplateClientPageProps = {
token: string;
@ -49,7 +51,7 @@ export type EmbedDirectTemplateClientPageProps = {
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean;
allowWhiteLabelling?: boolean;
};
export const EmbedDirectTemplateClientPage = ({
@ -60,12 +62,12 @@ export const EmbedDirectTemplateClientPage = ({
fields,
metadata,
hidePoweredBy = false,
isPlatformOrEnterprise = false,
allowWhiteLabelling = false,
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const searchParams = useSearchParams();
const [searchParams] = useSearchParams();
const {
fullName,
@ -76,7 +78,7 @@ export const EmbedDirectTemplateClientPage = ({
setEmail,
setSignature,
setSignatureValid,
} = useRequiredSigningContext();
} = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@ -94,7 +96,7 @@ export const EmbedDirectTemplateClientPage = ({
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [
localFields.filter((field) => !field.inserted),
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
localFields.filter((field) => field.inserted),
];
@ -112,7 +114,7 @@ export const EmbedDirectTemplateClientPage = ({
const newField: DirectTemplateLocalField = structuredClone({
...field,
customText: payload.value,
customText: payload.value ?? '',
inserted: true,
signedValue: payload,
});
@ -123,8 +125,10 @@ export const EmbedDirectTemplateClientPage = ({
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
signatureImageAsBase64:
payload.value && payload.value.startsWith('data:') ? payload.value : null,
typedSignature:
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
} satisfies Signature;
}
@ -182,7 +186,7 @@ export const EmbedDirectTemplateClientPage = ({
};
const onNextFieldClick = () => {
validateFieldsInserted(localFields);
validateFieldsInserted(pendingFields);
setShowPendingFieldTooltip(true);
setIsExpanded(false);
@ -194,7 +198,7 @@ export const EmbedDirectTemplateClientPage = ({
return;
}
const valid = validateFieldsInserted(localFields);
const valid = validateFieldsInserted(pendingFields);
if (!valid) {
setShowPendingFieldTooltip(true);
@ -207,12 +211,6 @@ export const EmbedDirectTemplateClientPage = ({
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
localFields.forEach((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
});
const {
documentId,
token: documentToken,
@ -223,13 +221,11 @@ export const EmbedDirectTemplateClientPage = ({
directRecipientName: fullName,
directRecipientEmail: email,
templateUpdatedAt: updatedAt,
signedFieldValues: localFields.map((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
return field.signedValue;
}),
signedFieldValues: localFields
.filter((field) => {
return field.signedValue && (isRequiredField(field) || field.inserted);
})
.map((field) => field.signedValue!),
});
if (window.parent) {
@ -288,7 +284,7 @@ export const EmbedDirectTemplateClientPage = ({
document.documentElement.classList.add('dark-mode-disabled');
}
if (isPlatformOrEnterprise) {
if (allowWhiteLabelling) {
injectCss({
css: data.css,
cssVars: data.cssVars,
@ -340,7 +336,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">
<LazyPDFViewer
<PDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
@ -349,7 +345,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
className="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}
>
<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">
@ -417,6 +413,7 @@ export const EmbedDirectTemplateClientPage = ({
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
@ -451,6 +448,7 @@ export const EmbedDirectTemplateClientPage = ({
</div>
)}
</div>
)}
</div>
</div>
@ -495,7 +493,7 @@ export const EmbedDirectTemplateClientPage = ({
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>

View File

@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import type { Signature } from '@documenso/prisma/client';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
export type EmbedDocumentCompletedPageProps = {
@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
};
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">

View File

@ -1,5 +1,5 @@
'use client';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -12,8 +12,6 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { type Field, FieldType } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@ -21,16 +19,16 @@ import type {
} from '@documenso/trpc/server/field-router/schema';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
import { InitialsField } from '~/app/(signing)/sign/[token]/initials-field';
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field';
import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field';
import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field';
import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field';
import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field';
import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
export type EmbedDocumentFieldsProps = {
fields: Field[];
@ -50,7 +48,7 @@ export const EmbedDocumentFields = ({
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField
<DocumentSigningSignatureField
key={field.id}
field={field}
onSignField={onSignField}
@ -59,7 +57,7 @@ export const EmbedDocumentFields = ({
/>
))
.with(FieldType.INITIALS, () => (
<InitialsField
<DocumentSigningInitialsField
key={field.id}
field={field}
onSignField={onSignField}
@ -67,7 +65,7 @@ export const EmbedDocumentFields = ({
/>
))
.with(FieldType.NAME, () => (
<NameField
<DocumentSigningNameField
key={field.id}
field={field}
onSignField={onSignField}
@ -75,7 +73,7 @@ export const EmbedDocumentFields = ({
/>
))
.with(FieldType.DATE, () => (
<DateField
<DocumentSigningDateField
key={field.id}
field={field}
onSignField={onSignField}
@ -85,7 +83,7 @@ export const EmbedDocumentFields = ({
/>
))
.with(FieldType.EMAIL, () => (
<EmailField
<DocumentSigningEmailField
key={field.id}
field={field}
onSignField={onSignField}
@ -99,7 +97,7 @@ export const EmbedDocumentFields = ({
};
return (
<TextField
<DocumentSigningTextField
key={field.id}
field={fieldWithMeta}
onSignField={onSignField}
@ -114,7 +112,7 @@ export const EmbedDocumentFields = ({
};
return (
<NumberField
<DocumentSigningNumberField
key={field.id}
field={fieldWithMeta}
onSignField={onSignField}
@ -129,7 +127,7 @@ export const EmbedDocumentFields = ({
};
return (
<RadioField
<DocumentSigningRadioField
key={field.id}
field={fieldWithMeta}
onSignField={onSignField}
@ -144,7 +142,7 @@ export const EmbedDocumentFields = ({
};
return (
<CheckboxField
<DocumentSigningCheckboxField
key={field.id}
field={fieldWithMeta}
onSignField={onSignField}
@ -159,7 +157,7 @@ export const EmbedDocumentFields = ({
};
return (
<DropdownField
<DocumentSigningDropdownField
key={field.id}
field={fieldWithMeta}
onSignField={onSignField}

View File

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

View File

@ -1,16 +1,22 @@
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { useEffect, useId, useLayoutEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import {
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
@ -19,20 +25,22 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { Logo } from '~/components/branding/logo';
import { BrandingLogo } from '~/components/general/branding-logo';
import { injectCss } from '~/utils/css-vars';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
import { injectCss } from '../../util';
import { ZSignDocumentEmbedDataSchema } from './schema';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = {
token: string;
@ -43,7 +51,7 @@ export type EmbedSignDocumentClientPageProps = {
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean;
allowWhitelabelling?: boolean;
allRecipients?: RecipientWithFields[];
};
@ -56,7 +64,7 @@ export const EmbedSignDocumentClientPage = ({
metadata,
isCompleted,
hidePoweredBy = false,
isPlatformOrEnterprise = false,
allowWhitelabelling = false,
allRecipients = [],
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
@ -70,11 +78,14 @@ export const EmbedSignDocumentClientPage = ({
setFullName,
setSignature,
setSignatureValid,
} = useRequiredSigningContext();
} = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [hasRejectedDocument, setHasRejectedDocument] = useState(
recipient.signingStatus === SigningStatus.REJECTED,
);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
@ -83,25 +94,34 @@ export const EmbedSignDocumentClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
),
fields.filter((field) => field.inserted),
];
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId();
const onNextFieldClick = () => {
validateFieldsInserted(fields);
validateFieldsInserted(fieldsRequiringValidation);
setShowPendingFieldTooltip(true);
setIsExpanded(false);
@ -113,7 +133,7 @@ export const EmbedSignDocumentClientPage = ({
return;
}
const valid = validateFieldsInserted(fields);
const valid = validateFieldsInserted(fieldsRequiringValidation);
if (!valid) {
setShowPendingFieldTooltip(true);
@ -161,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(() => {
const hash = window.location.hash.slice(1);
@ -174,12 +213,13 @@ export const EmbedSignDocumentClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (isPlatformOrEnterprise) {
if (allowWhitelabelling) {
injectCss({
css: data.css,
cssVars: data.cssVars,
@ -208,6 +248,10 @@ export const EmbedSignDocumentClientPage = ({
}
}, [hasFinishedInit, hasDocumentLoaded]);
if (hasRejectedDocument) {
return <EmbedDocumentRejected />;
}
if (hasCompletedDocument) {
return (
<EmbedDocumentCompleted
@ -225,14 +269,24 @@ export const EmbedSignDocumentClientPage = ({
}
return (
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && (
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
document={{ id: documentId }}
token={token}
onRejected={onDocumentRejected}
/>
</div>
)}
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<LazyPDFViewer
<PDFViewer
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
@ -241,7 +295,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
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}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
@ -372,6 +426,7 @@ export const EmbedSignDocumentClientPage = ({
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
@ -406,6 +461,7 @@ export const EmbedSignDocumentClientPage = ({
</div>
)}
</div>
)}
</>
)}
</div>
@ -420,7 +476,7 @@ export const EmbedSignDocumentClientPage = ({
</Button>
) : (
<Button
className="col-start-2"
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
@ -449,10 +505,10 @@ export const EmbedSignDocumentClientPage = ({
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
</RecipientProvider>
</DocumentSigningRecipientProvider>
);
};

View File

@ -1,10 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
export const EmbedWaitingForTurn = () => {
export const EmbedDocumentWaitingForTurn = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {

View File

@ -1,17 +1,15 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -42,16 +40,13 @@ export const ZDisable2FAForm = z.object({
export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
export const DisableAuthenticatorAppDialog = () => {
const router = useRouter();
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const [isOpen, setIsOpen] = useState(false);
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
const disable2FAForm = useForm<TDisable2FAForm>({
defaultValues: {
totpCode: '',
@ -84,7 +79,7 @@ export const DisableAuthenticatorAppDialog = () => {
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
try {
await disable2FA({ totpCode, backupCode });
await authClient.twoFactor.disable({ totpCode, backupCode });
toast({
title: _(msg`Two-factor authentication disabled`),
@ -97,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => {
onCloseTwoFactorDisableDialog();
});
router.refresh();
await refreshSession();
} catch (_err) {
toast({
title: _(msg`Unable to disable two-factor authentication`),

View File

@ -1,18 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { renderSVG } from 'uqr';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -50,29 +48,12 @@ export type EnableAuthenticatorAppDialogProps = {
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const { refreshSession } = useSession();
const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
const {
mutateAsync: setup2FA,
data: setup2FAData,
isPending: isSettingUp2FA,
} = trpc.twoFactorAuthentication.setup.useMutation({
onError: () => {
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
},
});
const [isSettingUp2FA, setIsSettingUp2FA] = useState(false);
const [setup2FAData, setSetup2FAData] = useState<{ uri: string; secret: string } | null>(null);
const enable2FAForm = useForm<TEnable2FAForm>({
defaultValues: {
@ -83,9 +64,36 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
const setup2FA = async () => {
if (isSettingUp2FA) {
return;
}
setIsSettingUp2FA(true);
setSetup2FAData(null);
try {
const data = await authClient.twoFactor.setup();
await refreshSession();
setSetup2FAData(data);
} catch (err) {
toast({
title: _(msg`Unable to setup two-factor authentication`),
description: _(
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
),
variant: 'destructive',
});
}
setIsSettingUp2FA(false);
};
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
try {
const data = await enable2FA({ code: token });
const data = await authClient.twoFactor.enable({ code: token });
await refreshSession();
setRecoveryCodes(data.recoveryCodes);
onSuccess?.();
@ -133,7 +141,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
router.refresh();
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -1,4 +1,4 @@
import { msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Copy } from 'lucide-react';

View File

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

View File

@ -1,22 +1,20 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ErrorCode, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { base64 } from '@documenso/lib/universal/base64';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Team, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
@ -31,6 +29,8 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export const ZAvatarImageFormSchema = z.object({
bytes: z.string().nullish(),
});
@ -39,15 +39,15 @@ export type TAvatarImageFormSchema = z.infer<typeof ZAvatarImageFormSchema>;
export type AvatarImageFormProps = {
className?: string;
user: User;
team?: Team;
};
export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps) => {
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
const { user, refreshSession } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const router = useRouter();
const team = useOptionalCurrentTeam();
const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation();
@ -103,13 +103,13 @@ export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps)
teamId: team?.id,
});
await refreshSession();
toast({
title: _(msg`Avatar Updated`),
description: _(msg`Your avatar has been updated successfully.`),
duration: 5000,
});
router.refresh();
} catch (err) {
const error = AppError.parseError(err);
@ -146,11 +146,7 @@ export const AvatarImageForm = ({ className, user, team }: AvatarImageFormProps)
<div className="flex items-center gap-8">
<div className="relative">
<Avatar className="h-16 w-16 border-2 border-solid">
{avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${avatarImageId}`}
/>
)}
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
<AvatarFallback className="text-sm text-gray-400">
{initials}
</AvatarFallback>

View File

@ -1,14 +1,12 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { authClient } from '@documenso/auth/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -36,7 +34,7 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const navigate = useNavigate();
const form = useForm<TForgotPasswordFormSchema>({
values: {
@ -47,10 +45,10 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
await forgotPassword({ email }).catch(() => null);
await authClient.emailPassword.forgotPassword({ email }).catch(() => null);
await navigate('/check-email');
toast({
title: _(msg`Reset email sent`),
@ -61,8 +59,6 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
});
form.reset();
router.push('/check-email');
};
return (

View File

@ -1,15 +1,14 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { AppError } from '@documenso/lib/errors/app-error';
import type { User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -39,7 +38,7 @@ export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
export type PasswordFormProps = {
className?: string;
user: User;
user: SessionUser;
};
export const PasswordForm = ({ className }: PasswordFormProps) => {
@ -57,11 +56,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
try {
await updatePassword({
await authClient.emailPassword.updatePassword({
currentPassword,
password,
});
@ -125,7 +122,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
<Trans>New Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput autoComplete="new-password" {...field} />

View File

@ -1,14 +1,11 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { User } from '@documenso/prisma/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -39,14 +36,12 @@ export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
export type ProfileFormProps = {
className?: string;
user: User;
};
export const ProfileForm = ({ className, user }: ProfileFormProps) => {
const router = useRouter();
export const ProfileForm = ({ className }: ProfileFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user, refreshSession } = useSession();
const form = useForm<TProfileFormSchema>({
values: {
@ -67,13 +62,13 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
signature,
});
await refreshSession();
toast({
title: _(msg`Profile updated`),
description: _(msg`Your profile has been updated successfully.`),
duration: 5000,
});
router.refresh();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -1,10 +1,10 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import type { TeamProfile, UserProfile } from '@prisma/client';
import { motion } from 'framer-motion';
import { AnimatePresence } from 'framer-motion';
import { CheckSquareIcon, CopyIcon } from 'lucide-react';
@ -12,9 +12,8 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
import type { TeamProfile, UserProfile } from '@documenso/prisma/client';
import {
MAX_PROFILE_BIO_LENGTH,
ZUpdatePublicProfileMutationSchema,
@ -90,8 +89,8 @@ export const PublicProfileForm = ({
const error = AppError.parseError(err);
switch (error.code) {
case AppErrorCode.PREMIUM_PROFILE_URL:
case AppErrorCode.PROFILE_URL_TAKEN:
case 'PREMIUM_PROFILE_URL':
case 'PROFILE_URL_TAKEN':
form.setError('url', {
type: 'manual',
message: error.message,

View File

@ -1,16 +1,14 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { authClient } from '@documenso/auth/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -43,7 +41,7 @@ export type ResetPasswordFormProps = {
};
export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => {
const router = useRouter();
const navigate = useNavigate();
const { _ } = useLingui();
const { toast } = useToast();
@ -58,15 +56,15 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
try {
await resetPassword({
await authClient.emailPassword.resetPassword({
password,
token,
});
await navigate('/signin');
form.reset();
toast({
@ -74,8 +72,6 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
description: _(msg`Your password has been updated successfully.`),
duration: 5000,
});
router.push('/signin');
} catch (err) {
const error = AppError.parseError(err);

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useLocation, useNavigate } from 'react-router';
import { useSearchParams } from 'react-router';
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
@ -11,10 +12,10 @@ export type SearchParamSelector = {
};
export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const router = useRouter();
const navigate = useNavigate();
const value = useMemo(() => {
const p = searchParams?.get(paramKey) ?? 'all';
@ -35,7 +36,7 @@ export const SearchParamSelector = ({ children, paramKey, isValueValid }: Search
params.delete(paramKey);
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (

View File

@ -1,12 +1,11 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { authClient } from '@documenso/auth/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -43,11 +42,9 @@ export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFo
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation();
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
try {
await sendConfirmationEmail({ email });
await authClient.emailPassword.resendVerifyEmail({ email });
toast({
title: _(msg`Confirmation email sent`),
@ -60,6 +57,7 @@ export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFo
form.reset();
} catch (err) {
toast({
variant: 'destructive',
title: _(msg`An error occurred while sending your confirmation email`),
description: _(msg`Please try again and make sure you enter the correct email address.`),
});

View File

@ -1,25 +1,22 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
import { Link, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { authClient } from '@documenso/auth/client';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
@ -44,19 +41,19 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
[ErrorCode.USER_MISSING_PASSWORD]:
'This account appears to be using a social login method, please sign in using that method',
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
[ErrorCode.UNVERIFIED_EMAIL]:
'This account has not been verified. Please verify your account before signing in.',
[ErrorCode.ACCOUNT_DISABLED]: 'This account has been disabled. Please contact support.',
const CommonErrorMessages: Record<string, MessageDescriptor> = {
[AuthenticationErrorCode.AccountDisabled]: msg`This account has been disabled. Please contact support.`,
};
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
const handleFallbackErrorMessages = (code: string) => {
const message = CommonErrorMessages[code];
if (!message) {
return msg`An unknown error occurred`;
}
return message;
};
const LOGIN_REDIRECT_PATH = '/documents';
@ -88,9 +85,8 @@ export const SignInForm = ({
}: SignInFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { getFlag } = useFeatureFlags();
const router = useRouter();
const navigate = useNavigate();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
@ -101,9 +97,7 @@ export const SignInForm = ({
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const isPasskeyEnabled = getFlag('app_passkey');
const callbackUrl = useMemo(() => {
const redirectPath = useMemo(() => {
// Handle SSR
if (typeof window === 'undefined') {
return LOGIN_REDIRECT_PATH;
@ -170,25 +164,20 @@ export const SignInForm = ({
try {
setIsPasskeyLoading(true);
const options = await createPasskeySigninOptions();
const { options, sessionId } = await createPasskeySigninOptions();
const credential = await startAuthentication(options);
const result = await signIn('webauthn', {
await authClient.passkey.signIn({
credential: JSON.stringify(credential),
callbackUrl,
redirect: false,
csrfToken: sessionId,
redirectPath,
});
if (!result?.url || result.error) {
throw new AppError(result?.error ?? '');
}
window.location.href = result.url;
} catch (err) {
setIsPasskeyLoading(false);
if (err.name === 'NotAllowedError') {
// Error from library.
if (err instanceof Error && err.name === 'NotAllowedError') {
return;
}
@ -196,12 +185,15 @@ export const SignInForm = ({
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_SETUP,
AuthenticationErrorCode.NotSetup,
() =>
msg`This passkey is not configured for this application. Please login and add one in the user settings.`,
)
.with(AppErrorCode.EXPIRED_CODE, () => msg`This session has expired. Please try again.`)
.otherwise(() => msg`Please try again later or login using your normal details`);
.with(
AuthenticationErrorCode.SessionExpired,
() => msg`This session has expired. Please try again.`,
)
.otherwise(() => handleFallbackErrorMessages(error.code));
toast({
title: 'Something went wrong',
@ -214,72 +206,59 @@ export const SignInForm = ({
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
try {
const credentials: Record<string, string> = {
await authClient.emailPassword.signIn({
email,
password,
};
if (totpCode) {
credentials.totpCode = totpCode;
}
if (backupCode) {
credentials.backupCode = backupCode;
}
const result = await signIn('credentials', {
...credentials,
callbackUrl,
redirect: false,
totpCode,
backupCode,
redirectPath,
});
} catch (err) {
console.log(err);
if (result?.error && isErrorCode(result.error)) {
if (result.error === TwoFactorEnabledErrorCode) {
const error = AppError.parseError(err);
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
setIsTwoFactorAuthenticationDialogOpen(true);
return;
}
const errorMessage = ERROR_MESSAGES[result.error];
if (result.error === ErrorCode.UNVERIFIED_EMAIL) {
router.push(`/unverified-account`);
if (error.code === AuthenticationErrorCode.UnverifiedEmail) {
await navigate('/unverified-account');
toast({
title: _(msg`Unable to sign in`),
description: errorMessage ?? _(msg`An unknown error occurred`),
});
return;
}
toast({
title: _(msg`Unable to sign in`),
description: errorMessage ?? _(msg`An unknown error occurred`),
variant: 'destructive',
});
return;
}
if (!result?.url) {
throw new Error('An unknown error occurred');
}
window.location.href = result.url;
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
msg`This account has not been verified. Please verify your account before signing in.`,
),
});
return;
}
const errorMessage = match(error.code)
.with(
AuthenticationErrorCode.InvalidCredentials,
() => msg`The email or password provided is incorrect`,
)
.with(
AuthenticationErrorCode.InvalidTwoFactorCode,
() => msg`The two-factor authentication code provided is incorrect`,
)
.otherwise(() => handleFallbackErrorMessages(error.code));
toast({
title: _(msg`Unable to sign in`),
description: _(errorMessage),
variant: 'destructive',
});
}
};
const onSignInWithGoogleClick = async () => {
try {
await signIn('google', {
callbackUrl,
await authClient.google.signIn({
redirectPath,
});
} catch (err) {
toast({
@ -294,8 +273,8 @@ export const SignInForm = ({
const onSignInWithOIDCClick = async () => {
try {
await signIn('oidc', {
callbackUrl,
await authClient.oidc.signIn({
redirectPath,
});
} catch (err) {
toast({
@ -365,7 +344,7 @@ export const SignInForm = ({
<p className="mt-2 text-right">
<Link
href="/forgot-password"
to="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
<Trans>Forgot your password?</Trans>
@ -384,7 +363,7 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{(isGoogleSSOEnabled || isPasskeyEnabled || isOIDCSSOEnabled) && (
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
@ -422,7 +401,6 @@ export const SignInForm = ({
</Button>
)}
{isPasskeyEnabled && (
<Button
type="button"
size="lg"
@ -435,7 +413,6 @@ export const SignInForm = ({
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
<Trans>Passkey</Trans>
</Button>
)}
</fieldset>
</form>

View File

@ -1,27 +1,22 @@
'use client';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
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 { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -38,14 +33,12 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/ui/user-profile-skeleton';
import { UserProfileTimur } from '~/components/ui/user-profile-timur';
const SIGN_UP_REDIRECT_PATH = '/documents';
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
import { UserProfileTimur } from '~/components/general/user-profile-timur';
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
export const ZSignUpFormV2Schema = z
export const ZSignUpFormSchema = z
.object({
name: z
.string()
@ -78,39 +71,39 @@ export const signupErrorMessages: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signups are disabled.`,
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
[AppErrorCode.PROFILE_URL_TAKEN]: msg`This username has already been taken`,
[AppErrorCode.PREMIUM_PROFILE_URL]: msg`Only subscribers can have a username shorter than 6 characters`,
PROFILE_URL_TAKEN: msg`This username has already been taken`,
PREMIUM_PROFILE_URL: msg`Only subscribers can have a username shorter than 6 characters`,
};
export type TSignUpFormV2Schema = z.infer<typeof ZSignUpFormV2Schema>;
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
export type SignUpFormV2Props = {
export type SignUpFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
};
export const SignUpFormV2 = ({
export const SignUpForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignUpFormV2Props) => {
}: SignUpFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
const searchParams = useSearchParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
const utmSrc = searchParams?.get('utm_source') ?? null;
const utmSrc = searchParams.get('utm_source') ?? null;
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
const form = useForm<TSignUpFormV2Schema>({
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
email: initialEmail ?? '',
@ -119,7 +112,7 @@ export const SignUpFormV2 = ({
url: '',
},
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormV2Schema),
resolver: zodResolver(ZSignUpFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
@ -127,13 +120,17 @@ export const SignUpFormV2 = ({
const name = form.watch('name');
const url = form.watch('url');
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormSchema) => {
try {
await signup({ name, email, password, signature, url });
await authClient.emailPassword.signUp({
name,
email,
password,
signature,
url,
});
router.push(`/unverified-account`);
await navigate(`/unverified-account`);
toast({
title: _(msg`Registration Successful`),
@ -153,10 +150,7 @@ export const SignUpFormV2 = ({
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
if (
error.code === AppErrorCode.PROFILE_URL_TAKEN ||
error.code === AppErrorCode.PREMIUM_PROFILE_URL
) {
if (error.code === 'PROFILE_URL_TAKEN' || error.code === 'PREMIUM_PROFILE_URL') {
form.setError('url', {
type: 'manual',
message: _(errorMessage),
@ -181,7 +175,7 @@ export const SignUpFormV2 = ({
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
await authClient.google.signIn();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
@ -195,7 +189,7 @@ export const SignUpFormV2 = ({
const onSignUpWithOIDCClick = async () => {
try {
await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH });
await authClient.oidc.signIn();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
@ -223,11 +217,10 @@ export const SignUpFormV2 = ({
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<Image
<img
src={communityCardsImage}
fill={true}
alt="community-cards"
className="dark:brightness-95 dark:contrast-[70%] dark:invert"
className="h-full w-full object-cover dark:brightness-95 dark:contrast-[70%] dark:invert"
/>
</div>
@ -426,10 +419,7 @@ export const SignUpFormV2 = ({
<p className="text-muted-foreground mt-4 text-sm">
<Trans>
Already have an account?{' '}
<Link
href="/signin"
className="text-documenso-700 duration-200 hover:opacity-70"
>
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Sign in instead
</Link>
</Trans>

View File

@ -1,17 +1,16 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';

View File

@ -1,19 +1,18 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { Trans } from '@lingui/react/macro';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -56,9 +55,9 @@ export const TeamDocumentPreferencesForm = ({
}: TeamDocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { data } = useSession();
const { user } = useSession();
const placeholderEmail = data?.user.email ?? 'user@example.com';
const placeholderEmail = user.email ?? 'user@example.com';
const { mutateAsync: updateTeamDocumentPreferences } =
trpc.team.updateTeamDocumentSettings.useMutation();

View File

@ -1,15 +1,13 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
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 { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
@ -31,20 +29,20 @@ export type UpdateTeamDialogProps = {
teamUrl: string;
};
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
const ZTeamUpdateFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
name: true,
url: true,
});
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
type TTeamUpdateFormSchema = z.infer<typeof ZTeamUpdateFormSchema>;
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
const router = useRouter();
export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
const navigate = useNavigate();
const { _ } = useLingui();
const { toast } = useToast();
const form = useForm({
resolver: zodResolver(ZUpdateTeamFormSchema),
resolver: zodResolver(ZTeamUpdateFormSchema),
defaultValues: {
name: teamName,
url: teamUrl,
@ -53,7 +51,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
const onFormSubmit = async ({ name, url }: TTeamUpdateFormSchema) => {
try {
await updateTeam({
data: {
@ -75,7 +73,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
});
if (url !== teamUrl) {
router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`);
await navigate(`/t/${url}/settings`);
}
} catch (err) {
const error = AppError.parseError(err);
@ -133,7 +131,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
{!form.formState.errors.url && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
`${WEBAPP_BASE_URL}/t/${field.value}`
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
<Trans>A unique URL to identify your team</Trans>
)}

View File

@ -1,12 +1,10 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@ -14,7 +12,6 @@ import { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiToken } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
@ -41,7 +38,15 @@ import {
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants';
import { useOptionalCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
THREE_MONTHS: msg`3 months`,
SIX_MONTHS: msg`6 months`,
ONE_YEAR: msg`12 months`,
} as const;
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
enabled: z.boolean(),
@ -56,29 +61,20 @@ type NewlyCreatedToken = {
export type ApiTokenFormProps = {
className?: string;
teamId?: number;
tokens?: Pick<ApiToken, 'id'>[];
};
export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => {
const router = useRouter();
const [isTransitionPending, startTransition] = useTransition();
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard();
const team = useOptionalCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
const [noExpirationDate, setNoExpirationDate] = useState(false);
// This lets us hide the token from being copied if it has been deleted without
// resorting to a useEffect or any other fanciness. This comes at the cost of it
// taking slighly longer to appear since it will need to wait for the router.refresh()
// to finish updating.
const hasNewlyCreatedToken =
tokens?.find((token) => token.id === newlyCreatedToken?.id) !== undefined;
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data);
@ -118,7 +114,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try {
await createTokenMutation({
teamId,
teamId: team?.id,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
@ -130,8 +126,6 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
});
form.reset();
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
@ -245,7 +239,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
type="submit"
className="hidden md:inline-flex"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting || isTransitionPending}
loading={form.formState.isSubmitting}
>
<Trans>Create token</Trans>
</Button>
@ -254,7 +248,7 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
<Button
type="submit"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting || isTransitionPending}
loading={form.formState.isSubmitting}
>
<Trans>Create token</Trans>
</Button>
@ -263,8 +257,10 @@ export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) =
</form>
</Form>
<AnimatePresence initial={!hasNewlyCreatedToken}>
{newlyCreatedToken && hasNewlyCreatedToken && (
<AnimatePresence>
{newlyCreatedToken &&
tokens &&
tokens.find((token) => token.id === newlyCreatedToken.id) && (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: -40 }}

View File

@ -1,23 +1,21 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetSignerConversionMonthlyResult } from '@documenso/lib/server-only/user/get-signer-conversion';
export type SignerConversionChartProps = {
export type AdminStatsSignerConversionChartProps = {
className?: string;
title: string;
cummulative?: boolean;
data: GetSignerConversionMonthlyResult;
};
export const SignerConversionChart = ({
export const AdminStatsSignerConversionChart = ({
className,
data,
title,
cummulative = false,
}: SignerConversionChartProps) => {
}: AdminStatsSignerConversionChartProps) => {
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),

View File

@ -1,5 +1,3 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { TooltipProps } from 'recharts';
@ -7,7 +5,7 @@ import type { NameType, ValueType } from 'recharts/types/component/DefaultToolti
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
export type UserWithDocumentChartProps = {
export type AdminStatsUsersWithDocumentsChartProps = {
className?: string;
title: string;
data: GetUserWithDocumentMonthlyGrowth;
@ -36,13 +34,13 @@ const CustomTooltip = ({
return null;
};
export const UserWithDocumentChart = ({
export const AdminStatsUsersWithDocumentsChart = ({
className,
data,
title,
completed = false,
tooltip,
}: UserWithDocumentChartProps) => {
}: AdminStatsUsersWithDocumentsChartProps) => {
const formattedData = (data: GetUserWithDocumentMonthlyGrowth, completed: boolean) => {
return [...data].reverse().map(({ month, count, signed_count }) => {
const formattedMonth = DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL');

View File

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

View File

@ -1,15 +1,13 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
import { Theme, useTheme } from 'remix-themes';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
@ -21,7 +19,6 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -34,7 +31,6 @@ import {
CommandList,
CommandShortcut,
} from '@documenso/ui/primitives/command';
import { THEMES_TYPE } from '@documenso/ui/primitives/constants';
import { useToast } from '@documenso/ui/primitives/use-toast';
const DOCUMENTS_PAGES = [
@ -70,16 +66,15 @@ const SETTINGS_PAGES = [
{ label: msg`Password`, path: '/settings/password' },
];
export type CommandMenuProps = {
export type AppCommandMenuProps = {
open?: boolean;
onOpenChange?: (_open: boolean) => void;
};
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const { _ } = useLingui();
const { setTheme } = useTheme();
const router = useRouter();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(() => open ?? false);
const [search, setSearch] = useState('');
@ -138,10 +133,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const push = useCallback(
(path: string) => {
router.push(path);
void navigate(path);
setOpen(false);
},
[router, setOpen],
[setOpen],
);
const addPage = (page: string) => {
@ -227,7 +222,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
</>
)}
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
{currentPage === 'theme' && <ThemeCommands />}
{currentPage === 'language' && <LanguageCommands />}
</CommandList>
</CommandDialog>
@ -256,19 +251,18 @@ const Commands = ({
));
};
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
const ThemeCommands = () => {
const { _ } = useLingui();
const THEMES = useMemo(
() => [
{ label: msg`Light Mode`, theme: THEMES_TYPE.LIGHT, icon: Sun },
{ label: msg`Dark Mode`, theme: THEMES_TYPE.DARK, icon: Moon },
{ label: msg`System Theme`, theme: THEMES_TYPE.SYSTEM, icon: Monitor },
],
[],
);
const [, setTheme] = useTheme();
return THEMES.map((theme) => (
const themes = [
{ label: msg`Light Mode`, theme: Theme.LIGHT, icon: Sun },
{ label: msg`Dark Mode`, theme: Theme.DARK, icon: Moon },
{ label: msg`System Theme`, theme: null, icon: Monitor },
] as const;
return themes.map((theme) => (
<CommandItem
key={theme.theme}
onSelect={() => setTheme(theme.theme)}
@ -294,9 +288,23 @@ const LanguageCommands = () => {
setIsLoading(true);
try {
await dynamicActivate(i18n, lang);
await switchI18NLanguage(lang);
} catch (err) {
await dynamicActivate(lang);
const formData = new FormData();
formData.append('lang', lang);
const response = await fetch('/api/locale', {
method: 'post',
body: formData,
});
if (!response.ok) {
throw new Error(response.statusText);
}
} catch (e) {
console.error(`Failed to set language: ${e}`);
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',

View File

@ -1,32 +1,28 @@
'use client';
import { type HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { MenuIcon, SearchIcon } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { getRootHref } from '@documenso/lib/utils/params';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Logo } from '~/components/branding/logo';
import { BrandingLogo } from '~/components/general/branding-logo';
import { CommandMenu } from '../common/command-menu';
import { DesktopNav } from './desktop-nav';
import { AppCommandMenu } from './app-command-menu';
import { AppNavDesktop } from './app-nav-desktop';
import { AppNavMobile } from './app-nav-mobile';
import { MenuSwitcher } from './menu-switcher';
import { MobileNavigation } from './mobile-navigation';
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
user: User;
user: SessionUser;
teams: TGetTeamsResponse;
};
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
const params = useParams();
const { pathname } = useLocation();
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
@ -42,8 +38,6 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
return () => window.removeEventListener('scroll', onScroll);
}, []);
const pathname = usePathname();
const isPathTeamUrl = (teamUrl: string) => {
if (!pathname || !pathname.startsWith(`/t/`)) {
return false;
@ -65,13 +59,13 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
>
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link
href={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
to={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
>
<Logo className="h-6 w-auto" />
<BrandingLogo className="h-6 w-auto" />
</Link>
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
<AppNavDesktop setIsCommandMenuOpen={setIsCommandMenuOpen} />
<div
className="flex gap-x-4 md:ml-8"
@ -89,9 +83,9 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
<MenuIcon className="text-muted-foreground h-6 w-6" />
</button>
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
<AppCommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
<MobileNavigation
<AppNavMobile
isMenuOpen={isHamburgerMenuOpen}
onMenuOpenChange={setIsHamburgerMenuOpen}
/>

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